W pierwszej części tego artykułu przedstawiłem podstawowe informacje dotyczące list inicjalizacyjnych składowych (ang. member initializer list). Są one wspólne dla wszystkich standardów języka C++, a więc także przed standardem C++11. W drugiej części omówię pewne cechy języka C++ związane z tematem list inicjalizacyjnych, które pojawiły się wraz z nowszymi standardami (poczynając od standardu C++11).
Inicjalizacja tablic
Nasze rozważania zacznijmy od następującego przykładu:
#include <iostream> #include <cstdio> template<uint32_t N> class NamedFixedBuffer { public: NamedFixedBuffer(char const *n) : name(n) , size(N) , buffer() { this->info(); } void info() { std::cout << "name : " << this->name << '\n'; std::cout << "size : " << this->size << '\n'; } void print() { for (uint32_t i = 0; i < this->size; ++i) { printf("0x%02X ", buffer[i]); // ... } } // ... private: std::string name; uint32_t size; uint8_t buffer[N]; // ... }; int main() { constexpr char const *name = "TestBuffer"; constexpr uint32_t size = 16; NamedFixedBuffer<size> nfb(name); nfb.print(); }
name : TestBuffer size : 16 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
Naszą uwagę skupimy teraz na inicjalizacji składowej buffer. Tym razem jest to ”surowa” tablica w stylu języka C. Zawiera ona elementy typu fundamentalnego uint8_t (pojedynczy bajt bufora). Oznacza to, że musimy jawnie przeprowadzić jego domyślną inicjalizację poprzez wyspecyfikowanie go na liścię inicjalizacyjnej (stosując składnię z pustymi nawiasami). W efekcie otrzymamy bufor wypełniony zerami (w przypadku braku jawnej specyfikacji na liście inicjalizacyjnej będą to niezdefiniowane wartości losowe). Jest to podejście prawidłowe także przed standardem C++11.
Sytuacja skomplikuje się w przypadku gdy będziemy chcieli zainicjalizować składową tablicową pewnymi konkretnymi wartościami. W przypadku standardu starszego niż C++11 taką inicjalizację musimy przeprowadzić w ciele konstruktora. Załóżmy więc, że musimy zainicjalizować pierwszy bajt naszego bufora, a pozostałe bajty pozostawić wyzerowane:
#include <iostream> #include <cstdio> template<uint32_t N> class NamedFixedBuffer { static_assert(N > 0, "Buffer must have non-zero size"); public: NamedFixedBuffer(char const *n) : name(n) , size(N) , buffer() { this->info(); // Init first byte buffer[0] = 0xFF; } void info() { std::cout << "name : " << this->name << '\n'; std::cout << "size : " << this->size << '\n'; } void print() { for (uint32_t i = 0; i < this->size; ++i) { printf("0x%02X ", buffer[i]); // ... } } // ... private: std::string name; uint32_t size; uint8_t buffer[N]; // ... }; int main() { constexpr char const *name = "TestBuffer"; constexpr uint32_t size = 16; NamedFixedBuffer<size> nfb(name); nfb.print(); }
name : TestBuffer size : 16 0xFF 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
Problem w tym rozwiązaniu polega na tym, iż do inicjalizacji poszczególnych elementów tablicy zmuszeni jesteśmy do używania ciała konstruktora. Ponadto w naszym przypadku mamy dwa etapy inicjalizacji: pierwszy na liście inicjalizacyjnej (zerowanie wszystkich bajtów bufora), a drugi w ciele konstruktora (przypisywanie wartości do określonych bajtów – u nas dla uproszczenia jest to jeden, pierwszy bajt).
Zadanie to można wykonać w jednym kroku na liście inicjalizacyjnej, stosując rozszerzoną składnię opartą na nawiasach klamrowych, dostępną w ramach listy inicjalizacyjnej od standardu C++11:
#include <iostream> #include <cstdio> template<uint32_t N> class NamedFixedBuffer { static_assert(N > 0, "Buffer must have non-zero size"); public: NamedFixedBuffer(char const *n) : name(n) , size(N) , buffer{ 0xFF } // Init first byte { this->info(); } void info() { std::cout << "name : " << this->name << '\n'; std::cout << "size : " << this->size << '\n'; } void print() { for (uint32_t i = 0; i < this->size; ++i) { printf("0x%02X ", buffer[i]); // ... } } // ... private: std::string name; uint32_t size; uint8_t buffer[N]; // ... }; int main() { constexpr char const *name = "TestBuffer"; constexpr uint32_t size = 16; NamedFixedBuffer<size> nfb(name); nfb.print(); }
name : TestBuffer size : 16 0xFF 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
Jak widać nie musimy specyfikować wszystkich wartości, bowiem pozostałe bedą zainicjalizowane wartościami domyślnymi (zerami). Dlatego też w naszej inicjalizacji wymieniliśmy tylko jeden (pierwszy) bajt, bowiem chcieliśmy aby pozostałe zostały zainicjalizowane zerami.
Konstruktory delegujące
Wróćmy teraz do przykładu klasy NamedBuffer, przy czym dodamy do niej konstruktor domyślny, inicjalizujący pola składowe z pewnymi domyślnymi ustawieniami (nazwa bufora i jego rozmiar):
#include <iostream> #include <memory> class NamedBuffer { public: // default ctor NamedBuffer() : name("Default") , size(256) , buffer(std::make_unique<uint8_t[]>(size)) { this->info(); } NamedBuffer(char const *n, uint32_t s) : name(n) , size(s) , buffer(std::make_unique<uint8_t[]>(size)) { this->info(); } void info() { std::cout << "name : " << this->name << '\n'; std::cout << "size : " << this->size << '\n'; } // ... private: std::string name; uint32_t size; std::unique_ptr<uint8_t[]> buffer; // ... }; int main() { constexpr char const *name = "TestBuffer"; constexpr uint32_t size = 1024; NamedBuffer nb1(name, size); NamedBuffer nb2; }
name : TestBuffer size : 1024 name : Default size : 256
Łatwo zauważyć, że listy inicjalizacyjne w obu konstruktorach są bardzo podobne. Jedyna różnica polega na tym, iż konstruktor domyślny inicjalizuje pola składowe name i size ”na sztywno” pewnymi domyślnymi wartościami, natomiast drugi konstruktor wykorzystuje w tym celu swoje parametry. Takie rozwiązanie stanie się uciążliwe, zwłaszcza kiedy kilka konstruktorów będzie musiało inicjalizować coraz większą ilość pól składowych. Wiąże się to z koniecznością utrzymywania podobnych list inicjalizacyjnych w kilku miejscach. Możemy oddelegować taką wspólną inicjalizację do innego konstruktora. W naszym przypadku proces inicjalizacji składowych oddelegujemy do konstruktora przyjmującego parametry, wywołując go na liście inicjalizacyjnej konstruktora domyślnego, a który jest naszym konstruktorem delegującym (ang. delegating constructor):
#include <iostream> #include <memory> class NamedBuffer { public: // default ctor NamedBuffer() : NamedBuffer("Defualt", 256) // delegation { } NamedBuffer(char const *n, uint32_t s) : name(n) , size(s) , buffer(std::make_unique<uint8_t[]>(size)) { this->info(); } void info() { std::cout << "name : " << this->name << '\n'; std::cout << "size : " << this->size << '\n'; } // ... private: std::string name; uint32_t size; std::unique_ptr<uint8_t[]> buffer; // ... }; int main() { constexpr char const *name = "TestBuffer"; constexpr uint32_t size = 1024; NamedBuffer nb1(name, size); NamedBuffer nb2; }
name : TestBuffer size : 1024 name : Defualt size : 256
Konstruktory delegujące pojawiły się wraz ze standardem C++11, a ich niewątpliwą zaletą jest możliwość eliminacji duplikacji kodu z procesu konstrukcji obiektu. Dzięki temu nasz kod źródłowy staje się prostszy i łatwiejszy w utrzymaniu (ang. maintenance).
Inicjalizacja składowych w klasie
Pola składowe możemy także zainicjalizować bezpośrednio w klasie (jest to możliwe od standardu C++11). Inicjalizacja taka nastąpi, jeśli składowa nie zostanie jawnie wyspecyfikowana na liście inicjalizacyjnej. Takie podejście można więc zastosować do zdefiniowania domyślnych wartości pól składowych. Jako przykład ponownie niech posłużą nam pola składowe name i size:
#include <iostream> #include <memory> class NamedBuffer { public: // default ctor NamedBuffer() // no name and size members : buffer(std::make_unique<uint8_t[]>(size)) { this->info(); } NamedBuffer(char const *n, uint32_t s) : name(n) , size(s) , buffer(std::make_unique<uint8_t[]>(size)) { this->info(); } void info() { std::cout << "name : " << this->name << '\n'; std::cout << "size : " << this->size << '\n'; } // ... private: std::string name = "Default"; // init (default) uint32_t size = 256; // init (default) std::unique_ptr<uint8_t[]> buffer; // ... }; int main() { constexpr char const *name = "TestBuffer"; constexpr uint32_t size = 1024; NamedBuffer nb1(name, size); NamedBuffer nb2; }
name : TestBuffer size : 1024 name : Default size : 256
Konstruktory dziedziczone
Załóżmy teraz, że chcemy rozszerzyć funkcjonalność naszego bufora o możliwość ładnego wyświetlania jego zawartości. W tym celu możemy zaimplementować nową klasę PrintableNamedBuffer dziedzicząc po klasie NamedBuffer:
#include <iostream> #include <memory> class NamedBuffer { public: NamedBuffer(char const *n, uint32_t s) : name(n) , size(s) , buffer(std::make_unique<uint8_t[]>(size)) { this->info(); } void info() { std::cout << "name : " << this->name << '\n'; std::cout << "size : " << this->size << '\n'; } // ... private: std::string name; uint32_t size; std::unique_ptr<uint8_t[]> buffer; // ... }; class PrintableNamedBuffer : public NamedBuffer { public: PrintableNamedBuffer(char const *n, uint32_t s) : NamedBuffer(n, s) { } // ... }; int main() { constexpr char const *name = "TestBuffer"; constexpr uint32_t size = 1024; PrintableNamedBuffer pnb(name, size); }
name : TestBuffer size : 1024
Jak widać konstruktor klasy PrintableNamedBuffer wywołuje analogiczny konstruktor z klasy bazowej NamedBuffer (implementacja konstruktora z klasy NamedBuffer jest dla nas wystarczająca) przy czym oba konstruktory przyjmują te same parametry. Inaczej mówiąc, głównym zadaniem konstruktora klasy pochodnej jest przekazanie wszystkich swoich parametrów (z zachowaniem ich kolejności) do konstruktora klasy bazowej. W takiej sytuacji możemy skorzystać z cechy dostępnej w standardzie C++11 dotyczącej konstruktora dziedziczonego (ang. inherited constructor). Aby skorzystać z takiego rozwiązania, potrzebujemy przenieść symbol konstruktora klasy bazowej wraz z jego ewentualnymi przeciążeniami, za pomocą deklaracji ze słowem kluczowym using (ang. using declaration):
using NamedBuffer::NamedBuffer;
Przypomina to troche przenoszenie przesłoniętych symboli (ang. shadowing) z zakresu klasy bazowej do zakresu widoczności klasy pochodnej (symbole z klasy bazowej są przesłonięte przez symbole z klasy pochodnej). W efekcie odziedziczony konstruktor z klasy bazowej zachowuje się tak jak wcześniej jawny konstruktor klasy pochodnej, propagujący swoje parametry do konstruktora klasy bazowej (dodatkowo inicjalizując domyślnie swoje nietrywialnie składowe):
#include <iostream> #include <memory> class NamedBuffer { public: NamedBuffer(char const *n, uint32_t s) : name(n) , size(s) , buffer(std::make_unique<uint8_t[]>(size)) { this->info(); } void info() { std::cout << "name : " << this->name << '\n'; std::cout << "size : " << this->size << '\n'; } // ... private: std::string name; uint32_t size; std::unique_ptr<uint8_t[]> buffer; // ... }; class PrintableNamedBuffer : public NamedBuffer { public: using NamedBuffer::NamedBuffer; // "inherited" ctor // ... }; int main() { constexpr char const *name = "TestBuffer"; constexpr uint32_t size = 1024; PrintableNamedBuffer pnb(name, size); }
name : TestBuffer size : 1024
Dziedziczenie to nie dotyczy konstruktora domyślnego, kopiującego i przenoszącego, bowiem konstruktory takie zostaną wygenerowane przez kompilator w klasie pochodnej, także bez deklaracji ze słowem kluczowym using, Konstruktory te wywołują niejawnie swoje odpowiedniki z klasy bazowej. Zachowanie to jest znane jeszcze przed standardem C++11 – w odniesieniu do konstruktora domyślnego i konstruktora kopiującego (oczywiście przed standaredem C++11 nie istniało pojęcie konstruktora przenoszącego).
Funkcja std::exchange
Przyjmijmy teraz, że nasza implementacja bufora oparta jest na ”gołym” wskaźniku (w ogólności nie jest to zalecane podejście, bowiem zamiast tego powinniśmy stosować klasy oparte na idiomie RAII, do których należą klasy inteligentnych wskaźników). Ponadto chcemy mieć możliwość przenoszenia obiektu bufora. Skupimy się więc na implementacji przenoszącego konstruktora kopiującego (jednocześnie dla uproszczenia pomijając implementację przenoszącego operatora przypisania):
#include <iostream> #include <memory> class NamedBuffer { public: NamedBuffer(char const *n, uint32_t s) : name(n) , size(s) , buffer(new uint8_t[size]) { this->info(); } // move ctor NamedBuffer(NamedBuffer && other) noexcept : name(std::move(other.name)) , size(other.size) , buffer(other.buffer) { other.size = 0; other.buffer = nullptr; this->info(); } NamedBuffer & operator=(NamedBuffer && other) noexcept { // ... return *this; } ~NamedBuffer() { delete [] buffer; buffer = nullptr; } void info() { std::cout << "name : " << this->name << '\n'; std::cout << "size : " << this->size << '\n'; std::cout << "buffer : " << static_cast<void *>(this->buffer) << '\n'; std::cout << '\n'; } // ... private: std::string name; uint32_t size; uint8_t *buffer; // raw pointer // ... }; int main() { constexpr char const *name = "TestBuffer"; constexpr uint32_t size = 1024; NamedBuffer nb1(name, size); NamedBuffer nb2 = std::move(nb1); nb1.info(); }
name : TestBuffer size : 1024 buffer : 0x1f08c50 name : TestBuffer size : 1024 buffer : 0x1f08c50 name : size : 0 buffer : 0
W konstruktorze przenoszącym przenosimy bezpośrednio, za pomocą funkcji std::move (plik nagłówkowy utility), składową name typu std::string (plik nagłówkowy string). Klasa std::string posiada swój własny konstruktor przenoszący, który w efekcie powoduje, że po przeniesieniu obiekt źródłowy tego typu przechowuje pusty łańcuch tekstowy. W przypadku typów prostych reprezentowanych przez składowe size (liczba całkowita) i buffer (wskaźnik) wykonujemy kopiowanie. Próba przeniesienia ich za pomocą funkcji std::move nie zmieni sytuacji i nadal będziemy mieć do czynienia z kopiowaniem. Spotkałem się z opinią, że jest to dobra praktyka. Zmodyfikujmy więc listę inicjalizacyjną w konstruktorze przenoszącym (teraz każda składowa podczas przenoszeni jest rzutowana na referencję do r-wartości za pośrednictwem funkcji std::move):
#include <iostream> #include <utility> class NamedBuffer { public: NamedBuffer(char const *n, uint32_t s) : name(n) , size(s) , buffer(new uint8_t[size]) { this->info(); } // move ctor NamedBuffer(NamedBuffer && other) noexcept : name(std::move(other.name)) , size(std::move(other.size)) // unnecessary std::move but good practice , buffer(std::move(other.buffer)) // ditto { other.size = 0; other.buffer = nullptr; this->info(); } NamedBuffer & operator=(NamedBuffer && other) noexcept { // ... return *this; } ~NamedBuffer() { delete [] buffer; buffer = nullptr; } void info() { std::cout << "name : " << this->name << '\n'; std::cout << "size : " << this->size << '\n'; std::cout << "buffer : " << static_cast<void *>(this->buffer) << '\n'; std::cout << '\n'; } // ... private: std::string name; uint32_t size; uint8_t *buffer; // raw pointer // ... }; int main() { constexpr char const *name = "TestBuffer"; constexpr uint32_t size = 1024; NamedBuffer nb1(name, size); NamedBuffer nb2 = std::move(nb1); nb1.info(); }
name : TestBuffer size : 1024 buffer : 0x19e3c50 name : TestBuffer size : 1024 buffer : 0x19e3c50 name : size : 0 buffer : 0
Przejdźmy teraz do ciała konstrukotra przenoszącego. Po przeniesieniu odpowiadających składowych naszym obowiązkiem jest pozostawienie obiektu źródłowego w prawidłowym stanie. W tym celu konieczne jest zerowanie wskaźnika buffer. W przecwinym razie, po wykonaniu operacji przeniesienia, otrzymamy dwa obiekty odwołujące się do tego samego obszaru pamięci. W efekcie oba obiekty w swoich destruktorach będą próbować zwalniać tę samą pamięć, co najprawdopodobniej doprowadzi do załamania programu. W przypadku składowej size, takie zerowanie nie jest konieczne, ale można je wykonać dla porządku. Inaczej mówiąc, obiekt źródłowy po przeniesieniu musi pozostawać w pewnym prawdłowym stanie, przy czym stan ten nie musi być z góry określony. Po przeniesieniu obiektu źródłowego zakładamy, że obiekt taki nie będzie później używany, przy czym po naszej stronie nadal pozostaje odpowiedzialność za jego prawidłowe niszczenie.
W tym momencie można zadać sobie pytanie, czy do przenoszenia obiektu musimy angażować ciało konstruktora przenoszącego. Okazuje się, że zadanie to można wykonać ograniczając się wyłącznie do listy inicjalizacyjnej tego konstruktora. Z pomocą przychodzi nam funkcja std::exchange (plik nagłówkowy utility) dostępna od standardu C++14. Oto przykładowa implementacja tej funkcji:
template<class T, class U = T> T exchange(T& obj, U&& new_value) { T old_value = std::move(obj); obj = std::forward<U>(new_value); return old_value; }
Implementacja listy inicjalizacyjnej konstruktora przenoszącego, oparta na funkcji std::exchange, działa zgodnie z oczekiwaniami:
#include <iostream> #include <utility> class NamedBuffer { public: NamedBuffer(char const *n, uint32_t s) : name(n) , size(s) , buffer(new uint8_t[size]) { this->info(); } // move ctor NamedBuffer(NamedBuffer && other) noexcept : name(std::move(other.name)) , size(std::exchange(other.size, 0)) // std::exchange , buffer(std::exchange(other.buffer, nullptr)) // ditto { // No assigment to members of source object this->info(); } NamedBuffer & operator=(NamedBuffer && other) noexcept { // ... return *this; } ~NamedBuffer() { delete [] buffer; buffer = nullptr; } void info() { std::cout << "name : " << this->name << '\n'; std::cout << "size : " << this->size << '\n'; std::cout << "buffer : " << static_cast<void *>(this->buffer) << '\n'; std::cout << '\n'; } // ... private: std::string name; uint32_t size; uint8_t *buffer; // raw pointer // ... }; int main() { constexpr char const *name = "TestBuffer"; constexpr uint32_t size = 1024; NamedBuffer nb1(name, size); NamedBuffer nb2 = std::move(nb1); nb1.info(); }
name : TestBuffer size : 1024 buffer : 0x2375c50 name : TestBuffer size : 1024 buffer : 0x2375c50 name : size : 0 buffer : 0
Jak widać zadanie przeniesienia obiektu źródłowego można wykonać wykorzystując do tego wyłącznie listę inicjalizacyjną konstrukotra przenoszącego. Pozwala to uprościć implementację konstruktora przenoszącego, bowiem proces przenoszenia obiektu nie jest już rozdzielony na listę inicjalizacyjną i ciało konstruktora. Inaczej mówiąc, takie rozwiązane nadal pozwala nam na tworzenie bardzo prostych konstruktorów, w których inicjalizacja odbywa się wyłącznie na liście inicjalizacyjnej (bez udziału ciała konstruktora).
Podsumowanie
- Listy inicjalizacyjne umożliwiają inicjalizację wartości znajdujących się w tablicy (dzięki rozszerzonej składni z wykorzystaniem nawiasów klamrowych).
- Konstruktor delegujący może wywołać na liście inicjalizacyjej inny konstruktor z tej samej klasy, delegując do niego zadanie konstrukcji obiektu – eliminuje to problem wynikający z konieczności implementacji podobnych list inicjalizacyjnych.
- Pola składowe mogą być inicjalizowane wewnątrz klasy – jest inicjalizacja domyślna, która zachodzi gdy dane pole składowe nie jest inicjalizowane na liście inicjalizacyjnej wywoływanego konstruktora.
- Dziedziczenie konstruktorów (z wyłączeniem konstruktora domyślnego, kopiującego oraz przenoszącego) jest możliwe dzięki deklaracji ze słowem kluczowym using. Dzięki temu w klasie pochodnej nie musimy implementować konstruktora, ktory na liście inicjalizacyjnej wywołuje wyłącznie konstruktor z klasy bazowej, przekazując mu dokładnie te same parametry.
- Funkcja std::exchange umożliwia prawidłowe przeniesienie obiektu wyłącznie z poziomu listy inicjalizacyjnej (bez udziału ciała konstruktora).