Podrozdziały


15.3 Konstruktory - dalsze szczegóły

O konstruktorach mówiliśmy już w jednym z poprzednich rozdziałów . Nie wyczerpaliśmy jednak tego tematu. Bardzo ważną rolę w C++ pełnią konstruktory kopiujące. Omówimy je w następnym podrozdziale. Innym ważnym mechanizmem jest mechanizm inicjowania tworzonych obiektów za pomocą list inicjalizacyjnych. Często jest on tylko wygodnym skrótem, czasem jednak jest konieczny do prawidłowego skonstruowania obiektu. Definiowanie konstruktorów kopiujących jest zazwyczaj konieczne w klasach zawierających pola wskaźnikowe; wtedy też niezbędne jest zdefiniowanie samemu destruktora i przedefiniowanie operatora przypisania.


15.3.1 Konstruktory kopiujące

Zadaniem konstruktora kopiującego jest utworzenie obiektu identycznego jak inny, istniejący wcześniej, obiekt tej samej klasy, pełniący wobec tego rolę wzorca. Obiekt ma być identyczny, ale z oryginałem całkowicie rozłączny; późniejsza modyfikacja jednego z nich nie powinna mieć wpływu na drugi.

Dla klasy A jest to konstruktor o sygnaturze ' A(const A&)'. Modyfikator const nie jest tu obowiązkowy, co za chwilę wyjaśnimy. Oczywiście rolę konstruktora kopiującego może też pełnić konstruktor, w którym pierwszy parametr jest typu const A& (lub A&), a pozostałe mają zdefiniowane wartości domyślne. W każdym razie pierwszym (i najczęściej jedynym) argumentem wywołania takiego konstruktora będzie zawsze istniejący wcześniej obiekt klasy  A, który zostanie do konstruktora kopiującego przesłany przez referencję.

Mogłoby się wydawać, że tego rodzaju konstruktor często w ogóle nie będzie potrzebny. Tak jednak nie jest: jest on potrzebny bardzo często, choć nie zawsze sami musimy go definiować. Zauważmy bowiem, że kopiowanie obiektów zachodzi zawsze, gdy obiekt pełni rolę argumentu wywołania funkcji, jeśli tylko przekazywany jest do funkcji przez wartość, a nie przez wskaźnik lub referencję; na stosie musi zostać położona kopia obiektu. Podobnie rzecz się ma przy zwracaniu przez wartość obiektu jako rezultatu funkcji: tu również jest wykonywana kopia obiektu. Za każdym razem w takim przypadku używany jest konstruktor kopiujący. Widzimy zatem, że trudno byłoby napisać program, w którym konstruktory kopiujące nie byłyby używane. Rozpatrzmy przykład programu, który nic nie robi prócz pisania informacji o wywołaniach konstruktorów.


P111: kopiow.cpp     Konstruktor kopiujący

      1.  #include <iostream>
      2.  using namespace std;
      3.  
      4.  class A {
      5.      double x;
      6.  public:
      7.      A(double x = 1) {
      8.          this->x = x;
      9.          cout << "W konstruktorze domyslnym"  << endl;
     10.      }
     11.  
     12.      A(const A& a) {
     13.          x = a.x;
     14.          cout << "W konstruktorze kopiujacym" << endl;
     15.      }
     16.  };
     17.  
     18.  A fun(A a) {
     19.      cout << "W funkcji fun" << endl;
     20.      return a;
     21.  }
     22.  
     23.  int main() {
     24.      cout << "**1**" << endl;
     25.      A a;
     26.  
     27.      cout << "**2**" << endl;
     28.      A b = a;                   
     29.  
     30.      cout << "**3**" << endl;
     31.      A c(b);                    
     32.  
     33.      cout << "**4**" << endl;   
     34.      c = fun(a);                
     35.  }

Program ten demonstruje przy okazji dwa sposoby tworzenia obiektów, których jeszcze nie omawialiśmy w rozdziale o tworzeniu obiektów . W linii  instrukcja ' A b = a;' tworzy na stosie (a więc jako zmienną lokalną) obiekt  b będący kopią wcześniej istniejącego obiektu  a. A zatem wywołany będzie konstruktor kopiujący! Zauważmy, że taki zapis nie ma nic wspólnego z przypisaniem: przypisywać można tylko do istniejących obiektów. Jest to prawie to samo co jawnie robimy w linii  za pomocą nieco różnej składni (' A c(b);'). Tu jawnie wywołujemy konstruktor klasy  A posyłając istniejący już teraz obiekt  b jako wzór. Jest drobna różnica między tymi przypadkami — za pierwszym razem wywołanie konstruktora kopiującego jest traktowane jako niejawne (ang. implicit) a w drugim jako jawne (ang. explicit). Jak się przekonamy, konstruktory mogą być zadeklarowane jako tylko jawne i w takim przypadku pierwsza forma nie zadziałałaby.

Przyglądając się wydrukowi z tego programu

    **1**
    W konstruktorze domyslnym
    **2**
    W konstruktorze kopiujacym
    **3**
    W konstruktorze kopiujacym
    **4**
    W konstruktorze kopiujacym
    W funkcji fun
    W konstruktorze kopiujacym
zauważamy, że gdy wydrukowany już został napis ' **4**', a więc po wykonaniu instrukcji z linii  programu, konstruktor kopiujący został jeszcze wywołany dwukrotnie, mimo że, jak się wydaje, żadnych nowych obiektów już nie kreujemy. Dlaczego zatem zadziałał? Konstruktor ten został wywołany dwa razy podczas obsługi wywołania funkcji fun w linii : Co by było, gdybyśmy w naszej klasie A konstruktora kopiującego nie napisali? Akurat w tym przypadku nic złego by się nie stało. Jeśli programista takiego konstruktora nie napisał, to zostanie on wygenerowany przez kompilator niezależnie od tego, czy w ogóle jakieś inne konstruktory dostarczyliśmy, czy nie (a zatem inaczej niż dla konstruktorów domyślnych). Wygenerowany automatycznie konstruktor kopiujący jest zawsze konstruktorem o dostępności typu public, tak jak automatycznie generowany konstruktor domyślny. Kopiuje on po prostu składowe obiektu-wzorca do odpowiednich składowych kreowanego obiektu. W przypadku tak prostej klasy jak klasa A z naszego przykładu, będzie to akurat to, o co nam chodzi: obiekt tej klasy zawiera tylko jedną składową (typu double). A zatem konstruktor kopiujący wygenerowany automatycznie wykonałby dokładnie to samo, co ten napisany przez nas (z wyjątkiem drukowania informacji o swoim działaniu).

Z tego, co powiedzieliśmy wynika, że w naszej klasie  A konstruktora kopiującego w ogóle nie musieliśmy pisać; służył w niej jedynie celom dydaktycznym. Jak się jednak przekonamy, nie zawsze tak jest.

Wyjaśnijmy jeszcze, dlaczego argument musi być referencją, a nie obiektem przekazywanym przez wartość. Gdyby był obiektem, to ponieważ argumenty przekazywane przez wartość są podczas wywołania kopiowane i kładzione na stosie, musiałaby najpierw zostać wykonana kopia tego obiektu. Ale do tego potrzebne byłoby ...wywołanie konstruktora kopiującego i przesłanie do niego przez wartość argumentu, a do tego znowu trzeba by wykonać kopię, a więc wywołać konstruktor kopiujący, i tak dalej, ad infinitum. Konstruktor kopiujący byłby zatem wywoływany rekursywnie w nieskończoność.

Z drugiej strony, przekazując do konstruktora obiekt-wzorzec przez referencję, dajemy mu możliwość zmiany tego obiektu-wzorca, dostaje on bowiem wtedy oryginał obiektu, a nie jego kopię. Najczęściej taka zmiana byłaby niepożądana. Dlatego właśnie, aby się przed możliwością takiej zmiany zabezpieczyć, parametr konstruktora kopiującego deklarujemy z modyfikatorem const. Sam kompilator zadba wtedy o to, abyśmy nawet nieświadomie czy przypadkowo nie zmodyfikowali obiektu-wzorca, a prócz tego będzie możliwe przekazywanie jako argumentu referencji do obiektu stałego (const).

Wewnątrz konstruktora kopiującego, jak i każdego innego, można odwoływać się do składowych tworzonego obiektu (które już istnieją). Można również wywoływać inne metody.

Jak wspomnieliśmy, automatycznie wygenerowany konstruktor kopiujący kopiuje obiekt-wzorzec na nowo tworzony obiekt „pole po polu”. Znaczy to, że kopiowanie takie jest płytkie: jeśli istnieją pola wskaźnikowe — np. dynamicznie tworzone tablice — elementem obiektu jest wtedy tylko wskaźnik, a nie cała tablica, a więc kopiowane są te wskaźniki, a nie obiekty przez nie wskazywane. W takich przypadkach programista musi dostarczyć właściwy konstruktor kopiujący zapewniający kopiowanie głębokie.

Zobaczmy o co tu chodzi na przykładzie. Rozważmy klasę Osoba, której składowymi ma być np. wiek i imię danej osoby:

   class Osoba {
       int   wiek;
       char* imie;
       // ...
   };
Pole wiek jest typu całkowitego i nie sprawia kłopotu. Gorzej jest z polem imie, które ma być napisem, czyli tablicą znaków. Zadeklarowaliśmy je jako typu char*. Dlaczego? Zastanówmy się, jaki wymiar ma mieć ta tablica? Tego nie wiemy: jedne imiona są krótkie, inne długie. Moglibyśmy ustalić maksymalną długość raz na zawsze pisząc
   class Osoba {
       int  wiek;
       char imie[20];
       // ...
   };
W każdym obiekcie klasy zawarta wtedy będzie tablica dwudziestu znaków. Zazwyczaj imiona są znacznie krótsze, więc większość pamięci przydzielonej w ten sposób na imiona będzie się marnować. Z drugiej strony, nie możemy za bardzo zmniejszyć tego wymiaru, bo jednak od czasu do czasu zdarzyć się mogą imiona rzeczywiście długie. Zatem decydujemy się na inne rozwiązanie: w obiekcie będziemy przechowywać tylko wskaźnik do tablicy znaków, a zatem wielkość typu char*, a samą tablicę będziemy alokować dynamicznie w konstruktorze dokładnie takiej wielkości, jaka będzie potrzebna: małą tablicę dla imion krótkich, większą dla długich. Do konstruktora będziemy zatem przesyłać tablicę znaków (czyli wskaźnik do istniejącej tablicy znaków zakończonej znakiem pustym ' \0').

Dlaczego w ogóle alokować tablicę w konstruktorze? Dlaczego nie napisać:

   class Osoba {
       int   wiek;
       char* imie;
   public:
       Osoba(char* im) {
           imie = im;
       }
       // ...
   };
Tak zrobić zazwyczaj nie można. Błędu formalnie tu nie ma, ale zauważmy, że w ten sposób w obiekcie zapamiętaliśmy adres pewnego napisu utworzonego gdzie indziej. Nie wiemy, czy jest on modyfikowalny czy nie, nie wiemy co się z tym napisem będzie dalej działo: może zaraz zostanie zmieniony albo usunięty, a obiekt zostanie z adresem czegoś, czego już w ogóle nie ma!

Zatem lepiej zrobimy, jeśli w konstruktorze zaalokujemy odpowiednią ilość miejsca w pamięci wolnej (na stercie) i tam ten napis przekopiujemy:


P112: osoba1.cpp     Alokowanie pamięci w konstruktorze

      1.  #include <iostream>
      2.  #include <cstring>
      3.  using namespace std;
      4.  
      5.  class Osoba1 { // niezupełnie dobra klasa...
      6.  public:
      7.      int   wiek;
      8.      char* imie;
      9.      Osoba1(int w, const char* im) {
     10.          wiek = w;
     11.          imie = new char[strlen(im)+1]; 
     12.          strcpy(imie,im);               
     13.      }
     14.  };
     15.  
     16.  int main() {
     17.      char imie[] = "Basia";
     18.  
     19.      Osoba1 basia(29, imie);
     20.  
     21.      imie[0] = 'K';                     
     22.  
     23.      cout << "Oryginal:  " << imie       << endl;
     24.      cout << "Z obiektu: " << basia.imie << endl;
     25.  }

Użyliśmy tu funkcji z nagłówka cstring

Zauważmy, że w konstruktorze przydzieliliśmy sobie (linia ) o jeden bajt więcej niż wynosi długość napisu przesłanego do tego konstruktora; jest to konieczne, gdyż przekopiować trzeba cały napis, łącznie ze znakiem ' \0' (co robimy w linii ). Program drukuje
    Oryginal:  Kasia
    Z obiektu: Basia
co przekonuje nas, że obiekt zawiera adres swojej „prywatnej” kopii napisu przesłanego do konstruktora. Oryginał tego napisu zmieniliśmy w linii Basia na Kasia, ale składowa imie obiektu wskazuje na tę „prywatną” kopię, która zmianie nie uległa.

Mamy tu zatem do czynienia z sytuacją, gdy obiekt fizycznie nie zawiera pełnej informacji o reprezentowanej przez siebie osobie (w tym przypadku nie zawiera imienia tej osoby), a tylko adres obszaru pamięci na stercie, gdzie ta informacja została zapisana. W ten sposób sam obiekt jest niewielki: zawiera tylko liczbę całkowitą (składowa wiek) i wskaźnik typu char* (składowa imie). Związana z nim informacja (tablica znaków zawierająca imię) jest zaalokowana poza obiektem, na stercie i zajmuje tylko tyle miejsca, ile jest to niezbędne.

Co teraz będzie, jeśli zechcemy użyć konstruktora kopiującego wygenerowanego przez system?


P113: osoba2.cpp     Pola wskaźnikowe i automatyczny konstruktor kopiujący

      1.  #include <iostream>
      2.  #include <cstring>
      3.  using namespace std;
      4.  
      5.  class Osoba2 { // niezupełnie dobra klasa...
      6.  public:
      7.      int   wiek;
      8.      char* imie;
      9.      Osoba2(int w, const char* im) {
     10.          wiek = w;
     11.          imie = new char[strlen(im)+1];
     12.          strcpy(imie,im);
     13.      }
     14.  };
     15.  
     16.  int main() {
     17.      char imie[] = "Basia";
     18.  
     19.      Osoba2 basia(29, imie);
     20.      Osoba2 kasia(basia);      // użycie konstr. kopiującego
     21.  
     22.      cout << "Po utworzeniu:   basia " << basia.imie << endl;
     23.      cout << "                 kasia " << kasia.imie << endl;
     24.  
     25.      kasia.imie[0] = 'K';
     26.  
     27.      cout << "Po zmianie Kasi: basia " << basia.imie << endl;
     28.      cout << "                 kasia " << kasia.imie << endl;
     29.  }

Na początku tworzymy obiekt basia o imieniu 'Basia'. W linii następnej tworzymy, poprzez konstruktor kopiujący, obiekt kasia. W obu obiektach imię jest 'Basia', o czym świadczą dwie pierwsze linie wydruku:

    Po utworzeniu:   basia Basia
                     kasia Basia
    Po zmianie Kasi: basia Kasia
                     kasia Kasia
Następnie (linia 25) zmieniamy imię w obiekcie kasia na Kasia. Jak widać z wydruku, spowodowało to również zmianę imienia Basi! Oczywiście, wiemy dlaczego tak się stało. Konstruktor kopiujący, tworząc obiekt kasia, przekopiował składową wskaźnikową imie, zawierającą adres, a nie sam napis, tak więc po utworzeniu obiektu kasia w obu obiektach składowa ta ma tę samą wartość: adres napisu zaalokowanego przez zwykły konstruktor podczas tworzenia obiektu basia. Obiekty są dwa, ale napis z imieniem jest tylko jeden, wskazywany przez składowe wskaźnikowe imie obu obiektów.

Tak oczywiście być nie powinno i to właśnie jest sytuacja, gdy automatyczny konstruktor kopiujący nie wykonuje tego, o co nam chodzi. Zatem sami musimy zadbać o to, by podczas tworzenia obiektu za pomocą konstruktora kopiującego każdy tworzony obiekt zaopatrzyć we własną kopię imienia:


P114: osoba3.cpp     Własny konstruktor kopiujący

      1.  #include <iostream>
      2.  #include <cstring>
      3.  using namespace std;
      4.  
      5.  class Osoba {   // trochę lepsza klasa
      6.  public:
      7.      int   wiek;
      8.      char* imie;
      9.      Osoba(int w, const char* im) { // zwykły konstruktor
     10.          wiek = w;
     11.          imie = new char[strlen(im)+1];
     12.          strcpy(imie,im);
     13.      }
     14.      Osoba(const Osoba& os) {      // konstruktor kopiujący
     15.          wiek = os.wiek;
     16.          imie = new char[strlen(os.imie)+1];
     17.          strcpy(imie,os.imie);
     18.      }
     19.      ~Osoba() {                    // destruktor
     20.          delete [] imie;
     21.      }
     22.  };
     23.  
     24.  int main() {
     25.      char imie[] = "Basia";
     26.  
     27.      Osoba basia(29, imie);
     28.      Osoba kasia(basia);
     29.  
     30.      cout << "Po utworzeniu:   basia " << basia.imie << endl;
     31.      cout << "                 kasia " << kasia.imie << endl;
     32.  
     33.      kasia.imie[0] = 'K';
     34.  
     35.      cout << "Po zmianie Kasi: basia " << basia.imie << endl;
     36.      cout << "                 kasia " << kasia.imie << endl;
     37.  }

W tym programie definiujemy „normalny” konstruktor, ale również konstruktor kopiujący, który kopiuje pole wiek, mierzy długość imienia w obiekcie-wzorcu i przydziela odpowiednią ilość miejsca na stercie, po czym kopiuje napis z imieniem wskazywany przez składową imie obiektu-wzorca do zaalokowanej pamięci. Wydruk

    Po utworzeniu:   basia Basia
                     kasia Basia
    Po zmianie Kasi: basia Basia
                     kasia Kasia
wskazuje, że tym razem napisy wskazywane przez składowe imie obu obiektów są inne: zmiana jednego z nich nie wpłynęła na drugi.

Dodaliśmy też do naszej klasy destruktor. W tej klasie jest on potrzebny. Tworząc obiekt, niezależnie od tego, który konstruktor został użyty, zaalokowaliśmy pewien obszar pamięci na stercie za pomocą operatora new. Nawet gdy obiekt jest lokalny (na stosie) i zostanie usunięty po wyjściu sterowania z bloku, w którym był zdefiniowany, pamięć, która do niego „należała” na stercie nie zostanie zwolniona. Ponieważ jednak wiemy, że gdy obiekt będzie usuwany, wywołany będzie automatycznie na jego rzecz destruktor, właśnie w nim umieszczamy kod zwalniający tę pamięć.

Tak skonstruowana klasa wciąż nie jest jeszcze prawidłowa. Jak się przekonamy, brakuje tu przeciążenia operatora przypisania (patrz rozdział o przeciążeniach ).


15.3.2 Listy inicjalizacyjne

Jak podkreślaliśmy, w momencie, gdy zaczyna działać konstruktor, obiekt już istnieje. W szczególności istnieją wszystkie jego składowe —  konstruktor może tylko zmienić ich wartość poprzez przypisania.

Pojawia się tu problem. Jak na przykład zainicjować składową odpowiadającą polu stałemu (z modyfikatorem const) albo referencyjnemu? W obu przypadkach składnia wymaga zainicjowania już w trakcie tworzenia, zatem nie można tego zrobić w konstruktorze — wtedy składowa już powinna istnieć! Co zrobić, gdy składową jest obiekt klasy, która nie ma konstruktora domyślnego?

We wszystkich tych przypadkach kompilacja nie uda się, jeśli sprawę inicjowania takich składowych pozostawimy dla konstruktora: wtedy jest już za późno.

Jednym ze sposobów zdefiniowania stałej jako składowej klasy jest użycie wyliczenia, które może być wtedy anonimowe:

   class A {
       enum { dim = 10 };
       int tab[dim];
   public:
       ...
       void fun() {
           ...
           for (int i = 0; i < dim; ++i) { ... }
           ...
       }
   };
Stała taka jednak musi mieć nadaną wartość bezpośrednio w kodzie programu i będzie taka sama dla wszystkich obiektów. W innych przypadkach inicjalizacja tego rodzaju składowych może odbywać się tylko na podstawie listy inicjalizacyjnej konstruktora (ang. initialization list).

Lista inicjalizacyjna to lista oddzielonych przecinkami identyfikatorów pól (składowych) z podanymi w nawiasach okrągłych argumentami dla konstruktorów obiektów będących składowymi tworzonego obiektu. Zwykle są to jednocześnie argumenty formalne definiowanego konstruktora, choć nie musi tak być. Jeśli argumentem przesyłanym do konstruktora obiektu składowego na liście inicjalizacyjnej jest obiekt tego samego typu, co ta składowa, to traktowane to będzie jako wywołanie konstruktora kopiującego. Taka składnia działa również dla składowych, które są typu wbudowanego — jak wielokrotnie podkreślaliśmy, twórcy języka starali się, aby reguły składniowe dla typów obiektowych i wbudowanych były do siebie tak podobne, jak to tylko możliwe.

Listę inicjalizacyjną, poprzedzoną dwukropkiem, umieszcza się bezpośrednio po nawiasie zamykającym listę parametrów konstruktora, a przed nawiasem klamrowym otwierającym definicję tego konstruktora.

Jeśli w klasie tylko deklarujemy konstruktor, a jego definicję podajemy poza klasą, to w deklaracji listy inicjalizacyjnej nie umieszczamy.

To jest logiczne: lista inicjalizacyjna należy logicznie do implementacji, a nie do interfejsu (kontraktu). Jak pamiętamy, odwrotnie było z argumentami domniemanymi (domyślnymi) — nie tylko konstruktorów, ale w ogóle funkcji; jeśli deklaracja występuje, to argumenty domniemane muszą być zdefiniowane właśnie w deklaracji, ale nie w definicji, bowiem ich wartość, i sama ich obecność, należy jak najbardziej do kontraktu z użytkownikiem (interfejsu).

Niezależnie od kolejności na liście inicjalizacyjnej,

składowe obiektu są inicjowane zawsze w kolejności ich deklaracji w ciele klasy.

Niektóre kompilatory wysyłają ostrzeżenia, jeśli kolejność deklaracji w definicji klasy i kolejność na liście inicjalizacyjnej nie są zgodne.

Na liście inicjalizacyjnej nie musimy wymieniać wszystkich składowych klasy. Te składowe, które nie zostały wymienione na liście, zainicjowane będą (przed rozpoczęciem wykonania ciała konstruktora!) przez:

Rozpatrzmy przykład: załóżmy, że mamy klasę  A ze zdefiniowanym jednym konstruktorem, który jednak nie może pełnić roli konstruktora domyślnego, bo zawsze wymaga argumentów. Drugi konstruktor dostarczy wtedy kompilator: będzie to konstruktor kopiujący.

       class A {
           //...
           A(int x, int y) { ... }
           //...
       };
Załóżmy dalej, że obiekty klasy  A są składowymi klasy  B. Ponieważ obiekty te muszą istnieć przed rozpoczęciem konstruktora, a do ich utworzenia potrzebne są argumenty, ich inicjalizacja musi nastąpić poprzez listę inicjalizacyjną:
      1.      class B {
      2.          A pole1, pole2;
      3.          //...
      4.          B(A a, int x, int y)
      5.              : pole1(a), pole2(x,y)
      6.          {  }
      7.          //  ...
      8.      };
Konstruktor B jest zdefiniowany w liniach 4-6 — jego ciało jest puste, bo wszystko co jest do zrobienia jest robione poprzez listę inicjalizacyjną. Składowa pole1 będzie zainicjowana za pomocą konstruktora kopiującego klasy A, bo argumentem jest obiekt tej klasy, natomiast składowa pole2 za pomocą konstruktora dwuargumentowego, zdefiniowanego w klasie A. Konstruktora kopiującego, co prawda, nie zdefiniowaliśmy, ale dostarczy go system.

Podobna sytuacja zajdzie, gdy pole klasy jest zadeklarowane jako stałe. Stałe muszą być inicjowane już w momencie tworzenia, a zatem nadawanie im wartości dopiero w konstruktorze nie wchodzi w rachubę. Trzeba to zrobić już na liście inicjalizacyjnej. To samo dotyczy pól odnośnikowych (referencyjnych); jak pamiętamy, patrz rozdział o referencjach , referencje, jak stałe, muszą być inicjowane już w momencie tworzenia.

Pola stałe stosuje się rzadko. Zazwyczaj wystarczy mechanizm ochrony danych poprzez umieszczenie składowej w sekcji prywatnej klasy. Również pola referencyjne nie występują często: składowa referencyjna jest inną nazwą czegoś spoza klasy, co stwarza niepotrzebną zwykle więź między obiektem a danymi spoza obiektu. Natomiast pola obiektowe, jak w powyższym przykładzie, występują często i w takich przypadkach stosowanie listy inicjalizacyjnej może być konieczne.


W poniższym przykładzie klasa Punkt nie ma konstruktora domyślnego. Istnieje tylko konstruktor zdefiniowany przez nas, pobierający dwie liczby typu double, oraz dostarczony przez system konstruktor kopiujący, który w tym przypadku jest odpowiedni i nie wymaga przedefiniowywania, gdyż klasa nie ma pól wskaźnikowych. Również destruktor nie jest potrzebny, gdyż z obiektem nie są związane żadne dane alokowane na stercie.


P115: trian.cpp     Lista inicjalizacyjna

      1.  #include <iostream>
      2.  using namespace std;
      3.  
      4.  struct Punkt {
      5.      double x, y;
      6.  
      7.      Punkt(double x, double y)
      8.          : x(x), y(y)
      9.      { }
     10.  
     11.      void show() const {
     12.          cout << "(" <<  x << "," << y << ")";
     13.      }
     14.  };
     15.  
     16.  struct Trojkat {
     17.      Punkt a, b, c;
     18.  
     19.      Trojkat(const Punkt&,const Punkt&,const Punkt&);   
     20.      Trojkat(double,double,double,double,double,double);
     21.  
     22.      void show() const {
     23.          cout << "Trojkat ";
     24.          a.show(); cout << "-";
     25.          b.show(); cout << "-";
     26.          c.show(); cout << endl;
     27.      }
     28.  };
     29.  
     30.  Trojkat::Trojkat(const Punkt &a, const Punkt &b,
     31.                                   const Punkt &c)
     32.      : a(a), b(b), c(c)                                 
     33.  { }
     34.  
     35.  Trojkat::Trojkat(double x1, double y1, double x2,
     36.                   double y2, double x3, double y3)
     37.      : a(x1,y1), b(x2,y2), c(x3,y3)                     
     38.  { }
     39.  
     40.  int main() {
     41.      Punkt a1(1,1), b1(2,2), c1(3,3);
     42.  
     43.      Trojkat T1(a1,b1,c1);
     44.  
     45.      Trojkat T2(11,22,22,33,33,44);
     46.  
     47.      T1.show();
     48.      T2.show();
     49.  }

Konstruktor klasy Punkt ma, jak widać, puste ciało; cała jego praca zostaje wykonana poprzez użycie listy inicjalizacyjnej. Zapis x(x) znaczy „zainicjuj składową  x (to jest  x zewnętrzne, poza nawiasem) posyłając do konstruktora wartość (lokalnej) zmiennej  x —  czyli tej związanej z parametrem konstruktora”. Oczywiście w naszym przypadku składowa jest typu wbudowanego; klasy double tak naprawdę nie ma, ale składnia wywołania konstruktora kopiującego, jak wspominaliśmy, może być użyta i w odniesieniu do typów wbudowanych.

Zauważmy, że w klasie Punkt lista inicjalizacyjna nie jest konieczna: składowe typów wbudowanych i tak byłyby utworzone bez kłopotów, a nadaniem im wartości moglibyśmy zająć się w ciele konstruktora.

Inaczej jest z klasą/strukturą Trojkat. Jej trzy pola są typu Punkt. A zatem podczas tworzenia obiektów klasy Trojkat muszą być utworzone, jeszcze przed wywołaniem konstruktora klasy Trojkat, trzy obiekty klasy Punkt, które są składowymi tego obiektu. Klasa Punkt nie ma jednak konstruktora domyślnego, zatem jedyną możliwością jest tu użycie listy inicjalizacyjnej. Odpowiednie konstruktory są tu zadeklarowane w liniach , a zdefiniowane poza klasą. Zauważmy, że lista inicjalizacyjna pojawia się wyłącznie w definicjach konstruktorów, ale nie w ich deklaracjach. Pierwszy z konstruktorów pobiera poprzez argumenty trzy punkty i przekazuje je poprzez listę inicjalizacyjną (linia ) do automatycznie wygenerowanego konstruktora kopiującego klasy Punkt. Drugi pobiera sześć liczb typu double i przekazuje je, parami, do konstruktora klasy Punkt pobierającego dwie liczby (linia ). Wynik tego programu

    Trojkat (1,1)-(2,2)-(3,3)
    Trojkat (11,22)-(22,33)-(33,44)
został uzyskany za pomocą metod show w obu klasach. Zauważmy, że metoda ta w klasie Trojkat korzysta jawnie z tak samo nazwanej metody z klasy Punkt. Obie te metody zostały zdefiniowane jako stałe, gdyż ich rolą jest wydrukowanie informacji o obiektach, a nie jakakolwiek modyfikacja tych obiektów.

Przykład klasy z polami ustalonymi i referencyjnymi podamy w następnym podrozdziale.

W nowym standardzie C++11, z listy inicjalizacyjnej jednego konstruktora można też wywołać inny konstruktor tej samej klasy, czyli jeden konstruktor „deleguje” pracę do drugiego (dlatego nazywamy go konstruktorem delegującym, delegating constructor). W takim przypadku na liście inicjalizacyjnej jednego konstruktora umieszczamy wyłącznie jeden element: nazwę danej klasy wraz z argumentami dla innego konstruktora. Ten inny konstruktor zostanie wtedy wykonany najpierw (zarówno to, co jest umieszczone na jego liście inicjalizacyjnej, jak i jego ciało), po czym sterowanie wraca do pierwotnie wywołanego konstruktora. Na przykład w programie


P116: delegconstr.cpp     Konstruktory delegujące

      1.  #include <iostream>
      2.  
      3.  class Point {
      4.      double x, y;
      5.  public:
      6.      Point(double x, double y) : x(x), y(y) {
      7.          std::cerr << "CTOR 1: (double,double)\n";
      8.      }
      9.  
     10.      Point(double x) : Point(x,0) {
     11.          std::cerr << "CTOR 2: (double)\n";
     12.      }
     13.      Point() : Point(0) {
     14.          std::cerr << "CTOR 3: ()\n";
     15.      }
     16.  };
     17.  
     18.  int main() {
     19.      std::cerr <<   "Point p1(1,1)\n";
     20.      Point p1(1,1);
     21.      std::cerr << "\nPoint p2(2)\n";
     22.      Point p2(2);
     23.      std::cerr << "\nPoint p3\n";
     24.      Point p3;
     25.  }

jak przekonuje jego wydruk

    Point p1(1,1)
    CTOR 1: (double,double)

    Point p2(2)
    CTOR 1: (double,double)
    CTOR 2: (double)

    Point p3
    CTOR 1: (double,double)
    CTOR 2: (double)
    CTOR 3: ()
podczas tworzenia obiektu p3 konstruktor domyślny deleguje do konstruktora drugiego, a ten z kolei do konstruktora pierwszego: wykonanie wraca potem do konstruktora drugiego i następnie znów do trzeciego.

T.R. Werner, 25 lutego 2017; 22:31