C++0x część 2

C++0x to propozycja nowego standardu języka C++. Została ona zaakceptowana przez komitet ISO 12 sierpnia 2011 roku. Tak oto narodził się C++11. Ponieważ zawiera on dużo zmian i nowości, zarówno po stronie samego języka, jak i biblioteki standardowej, warto się z nimi zapoznać już teraz.


Stopień trudności: 2


Dowiesz się:


Powinieneś wiedzieć:


Nie taki znowu ,,wstęp''...

Niniejszy artykuł jest drugim z cyklu prezentującego C++0x od strony praktycznej. Podobnie jak w przypadku pierwszej części skupimy się na praktycznych aspektach, które programista może od razu przetestować i używać. Użyjemy do tego celu kompilatora GCC, jako iż ma on obecnie najlepsze wsparcie dla nadchodzącego standardu.

Zgodnie z zapowiedzią z części pierwszej będziemy trzymać się konwencji używania nowych notacji oraz słów kluczowych wprowadzonych przez C++0x wszędzie, gdzie jest to tylko możliwe. Ma to na celu oswojenie czytelnika z nowymi elementami składni jak i utrwalenie już poznanego materiału.

W pierwszej części cyklu wprowadzonych zostało dużo nowych mechanizmów. Część z nich będzie używana w niniejszym odcinku. W związku z tym faktem autorzy sugerują przypomnienie sobie owego materiału przed przystąpieniem do lektury niniejszego artykułu.

W szczególności przydatny podczas czytania i samodzielnego testowania nowych możliwości będzie ,,magiczny'' typ auto (dedukowany przez kompilator). Ponieważ przedstawione są wskaźniki inteligentne, nie sposób również ominąć stosowania wartości wskaźnika pustego - stałej nullptr. Poruszając tematykę szablonów przyda się także wiedza o nowych szablonach zmiennej długości (np. template<typename ...Args>class VarT{/*...*/};) oraz możliwości sprawdzania parametrów w czasie kompilacji (static_assert(sizeof(TheAnswer)==42, "invalid answer")).

Kompilowanie przykładów
Do kompilowania przykładów należy użyć kompilatora GCC w wersji 4.6, ustawiając standard języka na C++0x:
g++-4.6 -Wall -std=c++0x src.cpp
Większość prezentowanych fragmentów kodu NIE będzie działać na starszych wersjach, które miały minimalne lub żadne wsparcie dla C++0x. W przypadku nowszych wersji GCC niektóre przykłady mogą wymagać drobnych modyfikacji. Pamiętajmy, że w chwili wydania GCC 4.6 C++0x nie był zatwierdzonym standardem. Oprócz tego GCC nie jest jeszcze w pełni zgodny z propozycją standardu języka.

Dzień dobry 0x0B!

Dnia 12 sierpnia 2011 roku propozycja standardu, nazywana skrótowo C++0x, została oficjalnie i jednogłośnie zaakceptowana jako nowy standard ISO. Przygotowanie oficjalnego dokumentu ISO potrwa jeszcze trochę czasu. Herb Sutter sugeruje, iż powinien się on ukazać przed końcem tego roku.

Można już mówić ,,C++'' w odniesieniu do najnowszej wersji standardu, jednak ponieważ wsparcie kompilatorów nadal jest odległe od pełnego, warto póki co jawnie zaznaczać, iż chodzi o C++11.

Owa konwencja nazewnicza będzie także utrzymana w niniejszym cyklu. Pisząc o ,,C++'', będziemy mieli na myśli ,,C++03''. Opisując elementy nowe (z ,,C++0x'') będziemy jawnie odnosić się do standardu C++11.

C++0x zmieniał się (miejscami dość znacząco) w czasie by ostatecznie stać się C++11. W chwili pisania tych słów, C++0x od kilku dni jest już terminem historycznym na rzecz C++11. W obliczu zaistniałej sytuacji pojawiła się również dyskusja nad nazwą cyklu. Czy i ona powinna ulec zmianie? Po rozważeniu za i przeciw, autorzy wybrali wersję mniej inwazyjną. Postanowili pozostać przy oryginalnym tytule - z ,,C++0x'' w nazwie.

Lista inicjalizacyjna

Idea listy inicjalizacyjnej w C++11 polega na użyciu zamkniętej w nawiasach klamrowych liście wyrażeń dla wszystkich kontekstów inicjalizacji. Dotychczas możliwe to było jedynie w przypadku inicjalizacji tablic - listing 1a.

int t[] = {1, 2, 3};
Listing 1a: Inicjalizacja tablicy z użyciem notacji {...}

Z listami inicjalizacyjnymi związany jest szablon klasy std::initializer_list<T> zdefiniowany w pliku nagłówkowym <initializer_list>. Jest to ,,twór'' z pogranicza biblioteki i kompilatora (języka). Jego zadaniem jest transformacja sekwencji elementów typu T do tablicy T[n], gdzie n jest liczbą elementów zapisanych między nawiasami klamrowymi. Wewnętrznie std::initializer_list<T> przechowuje referencję do elementów. W C++11 std::initializer_list<T> jest typem, więc może zostać użyty jako argument funkcji - listing 1b.

template<typename T>
void foo(std::initializer_list<T> list)
{
 std::cout << "Initializer list size: " << list.size() << endl;
 for(auto it = list.begin(); it != list.end(); ++it)
  std::cout << *it << endl;
}
// ...
foo( {"ala", "ma", "kota"} );
foo( {1 ,4, 8} );
foo({}); // pusta lista
Listing 1b: Użycie std::initializer_list<T> jako argumentu funkcji

Innym przykładem zastosowania std::initializer_list<T> jest użycie w konstruktorze klasy. Obiekty klasy container można tworzyć wykorzystując listę inicjalizacyjną - listing 1c.

template<typename T>
class Container
{
public:
 Container(std::initializer_list<T> l)
 {
 for(auto it=l.begin(); it != l.end(); ++it)
  vec_.push_back(*it);
 }
private:
 std::vector<T> vec_;
};
// ...
Container<std::string> c1 = {"ala", "ma", "kota"};
Container<int> c2 = {1, 2, 3};
Listing 1c: Użycie std::initializer_list<T> w konstruktorze

Ujednolicona inicjalizacja

W C++ istnieje wiele sposobów na zainicjalizowanie obiektów w zależności od typu i kontekstu inicjalizacji. Nieprawidłowa inicjalizacja może skutkować niezrozumiałym błędem. Warto zwrócić tutaj uwagę na istniejącą w C++ niejednoznaczność związaną z użyciem nawiasów okrągłych (). Nawiasy te są wykorzystywane zarówno w przypadku definicji zmiennych, jak i deklaracji funkcji. Z tego powodu twórcy standardu C++11 opracowali jednolity standard dla inicjalizacji obiektów. Na listingu 2a zostały przedstawione niejednoznaczności związane z inicjalizacją w C++.

int a(1); // definicja zmiennej
int b(); // deklaracja funkcji
int b(foo); // definicja zmiennej lub deklaracja funkcji
Listing 2a: Przykład niejednoznaczności w kontekście inicjalizacji w C++

Standard C++11 dostarcza zunifikowanej metody inicjalizacji obiektów poprzez użycie list inicjalizacyjnych dla wszystkich typów inicjalizacji. Programista może zainicjalizować prawie każdy obiekt (także kontenery STL) używając notacji nawiasowej - {...}. Inicjalizacja obiektów pewnej klasy A z wykorzystaniem notacji {...} została przedstawiona na listingu 2b.

class A
{
public:
 A(int a, int b): a_(a), b_(b) {}
 const int sum() const
 {
  return a_ + b_;
 }
private:
 int a_;
 int b_;
};
// ...
A a1 = A{4, 2};
A a2 = {4, 2};
A a3{4, 2};
A *p = new A{1, 2};
Listing 2b: Tworzenie obiektów klasy A

Poruszając temat niejednoznaczności związanych z inicjalizacją warto wspomnieć o problemie zwanym ,,most vexing parse''. Problem ten dotyczy niejednoznaczności między deklaracją funkcji, a wywołaniem konstruktora, którego argumentami są zmienne tymczasowe. Problem ten został przedstawiony na listingu 2c. Tworzenie obiektu klasy T przez wywołanie T t1( S() ); na pierwszy rzut oka wydaje się poprawnym tworzeniem obiektu t1. Kompilator jednak zinterpretuje ten kod jako deklarację funkcji. Zachowanie to jest naleciałością ,,z czasów C'', gdzie każda deklaracja, która mogła być zinterpretowana jako funkcja, była deklaracją funkcji. Aby poprawnie zainicjalizować obiekt klasy T należy użyć dodatkowych nawiasów okrągłych. Twórcy standardu C++11 zadbali o to, aby wyeliminować tego typu niejednoznaczności poprzez związanie inicjalizacji wszelkiego typu obiektów z operatorem {...}.

class S
{
public:
 S(){ cout << "Konstruktor S" << endl; }
};
class T
{
public:
 T(const S&){ cout << "Konstruktor T" << endl; }
};
// ...
T t1( S() ); // deklaracja funcji
T t2( ( S() ) ); // poprawna inicjalizacja obiektu w C++03
T t3{ S() }; // brak niejednoznaczności
T t4{ S{} }; // preferowana inicjalizacja obiektu w C++11
Listing 2c: Problem ,,most vexing parse''.

Przykład wywołania konstruktora klasy bazowej zgodnie ze standardem C++11 został przedstawiony na listingu 2d.

class B: A
{
public:
 B(int a, int b): A{a, b} {}
};
Listing 2d: Wywołanie konstruktora klasy bazowej

Zastosowanie nowej metody inicjalizacji bardzo przydaje się w przypadku gdy polem klasy jest tablica. Przykład takiej klasy został przedstawiony na listingu 2e. Inicjalizacja elementów tablicy nie wymaga odwołania się do każdego elementu tablicy w konstruktorze klasy przez podanie jego indeksu. tzn. t_[0]=a; t_[1]=b; t_[2]=c;. W C++11 zapis jest dużo prostszy i eliminuje sytuację podania indeksu wykraczającego poza zakres tablicy. Inicjalizacja tablicy na liście pozwala na to, aby była ona polem stałym (const).

class S
{
public:
 S(int a, int b, int c): t_{a, b, c} {}
private:
 const int t_[3];
};
Listing 2e: Inicjalizacja składowej klasy będącej tablicą

Inicjalizacja obiektu możne mieć miejsce także w chwili zwracania go przez funkcję. Sytuacja taka została przedstawiona na listingu 2f.

A f()
{
 return {1, 2};
}
Listing 2f: Inicjalizacja elementu zwracanego przez funkcję

Obiekt można również zainicjalizować podając go jako argument funkcji. Dla klasy z listingu 2b wywołanie funkcji z listingu 2g może wyglądać następująco: printSum({1, 2}).

void printSum(A a)
{
 std::cout << a.sum() << std::endl;
}
Listing 2g: Funkcja wypisująca sumę

Wprowadzenie listy inicjalizacyjnej znacznie uprościło inicjalizację kontenerów z biblioteki standardowej. Na przykład aby w C++03 zainicjalizować kontener std::vector kilkoma wartościami dla każdej z nich należało wywołać metodę push_back() (listing 2h). Nowy sposób inicjalizacji kontenera std::vector został przedstawiony na listingu 2i. Kod z listingu 2h zajmuje mniej miejsca i jest bardziej przejrzysty niż kod z listingu 2g. Dla innych kontenerów inicjalizacja z wykorzystaniem notacji {...} wygląda analogicznie jak dla przedstawionego przykładu.

std::vector<int> v;
v.reserve(3);
v.push_back(42);
v.push_back(2);
v.push_back(13);
Listing 2h: Inicjalizacja kontenera std::vector w C++03

/*const*/ std::vector<int> v = {42, 2, 13};
Listing 2i: Inicjalizacja kontenera std::vector w C++11.

Przy okazji omawiania nowego sposobu inicjalizacji obiektów przy użyciu operatora {...} warto przyjrzeć się temu jak nowy standard wpłynął na inicjalizację kontenerów z biblioteki standardowej. Na listingu 2j zostały przedstawione przykłady inicjalizacji kontenera std::vector przy użyciu operatora (...) oraz operatora {...}. Dla kontenerów v1, v2, v3 oraz v4 różnice w inicjalizacji obiektów wynikają z faktu, że dla operatora {...} wywoływany jest konstruktor biorący jako argument listę inicjalizacyjną (std::initializer_list). Podczas inicjalizacji kontenera v5 zostanie wywołany najbardziej pasujący konstruktor - czyli tworzący kontener zawierający cztery elementy typu std::string.

// dwa elementy o wartościach 10 i 2
std::vector<int> v1{10, 2};
// dzisięć elementów o wartościach 2
std::vector<int> v2(10, 2);
// jeden element o wartości 4
std::vector<int> v3{4};
// cztery elementy
std::vector<int> v4(4);
// cztery elementy std::string()
std::vector<std::string> v5{4};
Listing 2j: Różne sposoby inicjalizacji kolekcji std::vector

Podsumowując ten przykład można podać zasady związane z inicjalizacją w C++11:

  1. użycie operatora (...) powoduje wywołanie odpowiedniego konstruktora (kontenery v2 i v4),
  2. użycie operatora {...} powoduje wywołanie tzw. konstruktora sekwencyjnego (z listą inicjalizacyjną; kontenery v1 i v3),
  3. jeżeli dla inicjalizacji przy użyciu operatora {...} nie istnieje odpowiedni konstruktor sekwencyjny, to do konstrukcji obiektu zostanie dopasowany jeden z konstruktorów wywoływany przez operator (...) (kontener v5).

Z dynamiczną pamięcią za pan brat

Na wstępie przeanalizowany zostanie słynny fragment kodu Herba Suttera z książki Exceptional C++: 47 Engineering Puzzles, Programming Problems, and Solutions. Pytanie: ile jest możliwych ścieżek wykonania dla kodu z listingu 3a? Prosimy przejrzeć uważnie podany fragment kodu przed udzieleniem odpowiedzi.

String EvaluateSalaryAndReturnName( Employee e )
{
 if( e.Title() == "CEO" || e.Salary() > 100000 )
 {
  cout << e.First() << " " << e.Last() << " is overpaid" << endl;
 }
 return e.First() + " " + e.Last();
}
Listing 3a: Przykładowy kod - ile jest możliwych ścieżek wykonania?

Kod z listingu 3a może mieć dwadzieścia trzy (!) różne ścieżki wykonania. Sama logika jawnie przewiduje 3 ścieżki wykonania. Pierwsza - jeżeli warunek e.Title() == "CEO" zwróci wartość true to druga część kodu w warunku if nie zostanie wykonana (sprawdzenie i porównanie e.Salary()), a funkcja cout wypisze komunikat. Druga - jeżeli e.Title != "CEO", a e.Salary() > 10000 to obie części warunku if zostaną wykonane, a funkcja cout wypisze komunikat na ekran. Trzecia - jeżeli e.Title() != "CEO", a e.Salary() <= 100000 to komunikat nie zostanie wypisany.

Pozostałe 20 możliwych ścieżek wykonania rozpatrywanego kodu jest związanych głównie z możliwością wystąpienia sytuacji wyjątkowych. Na przykład argument jest przekazywany do funkcji przez wartość, co powoduje wywołanie konstruktora kopiującego, który może zgłosić wyjątek. Podobnie funkcje Title() i Salary() mogą zgłosić wyjątek. Dodając do tego możliwość wykonania pewnych fragmentów kodu w różnej kolejności (np: wyliczanie argumentów operatora strumieniowego) otrzymujemy w sumie 23 możliwe ścieżki wykonania.

Istotną kwestią w sytuacji zgłoszenia wyjątku jest brak automatycznego wywołania destruktorów obiektów utworzonych dynamicznie przed pojawieniem się wyjątku. Oczywiście można pokusić się o dopisanie funkcjonalności realizujących zwalnianie zasobów w takich sytuacjach korzystając z konstrukcji try{}catch(). Nie jest to jednak najlepszym rozwiązaniem, ponieważ prosty kod realizujący nieskomplikowane funkcjonalności stanie się nieczytelny, a pisanie kodu odpornego na wyjątki będzie wymagało ciągłego kontrolowania najmniejszych nawet szczegółów implementacji elementów składowych.

Powyższa analiza pokazuje, jak skomplikowany jest realny system i uświadamiający, iż zapanowanie nad całym kodem poprzez ,,ręczną'' obsługę możliwych błędów jest w praktyce nierealne. Idealnym rozwiązaniem w takich sytuacjach jest użycie inteligentnych wskaźników. Bezpieczny ze względu na wyjątki kod autorstwa Herba Suttera został przedstawiony na listingu 3b.

std::unique_ptr<String> EvaluateSalaryAndReturnName( Employee e )
{
 std::unique_ptr<String> result ( new String( e.First() + " " + e.Last() ) );
 if( e.Title() == "CEO" || e.Salary() > 100000 )
 {
  String message = (*result) + " is overpaid\n";
  cout << message;
 }
 return std::move( result );
}
Listing 3b: Kod bezpieczny ze względu na wyjątki

Do przekazania własności obiektu result na zewnątrz funkcji z listingu 3b została wykorzystana funkcja std::move(). Funkcja ta wymusza przenoszenie danego obiektu i zwraca obiekt z własnością. Na potrzeby tego fragmentu wystarczy taka informacja. Więcej szczegółów na temat funkcji std::move() czytelnik będzie mógł uzyskać z następnego odcinka cyklu, w całości poświęconego tematyce przenoszenia własności.

Inteligentne wskaźniki (ang. smart pointers) to abstrakcyjne typy danych, które wyglądają i zachowują się jak wskaźniki, ale są sprytniejsze. Podstawową zaletą inteligentnych wskaźników jest automatyczne zwalnianie zaalokowanej pamięci w chwili niszczenia obiektu inteligentnego wskaźnika. Zapobiega to występowaniu wycieków pamięci (niezależnie od występowania wyjątków) oraz zmniejsza liczbę linii kodu. Zarządzanie pamięcią w C++ jest źródłem znacznej liczby błędów. Użycie inteligentnych wskaźników pozwala na jej zmniejszenie - praktycznie do 0 (słownie: zera).

Standard C++11 definiuje następujące inteligentne wskaźniki: unique_ptr, shared_ptr oraz weak_ptr. Ich definicje zawarte są w pliku nagłówkowym <memory>. Biblioteka standardowa nadal dostarcza sprytny wskaźnik std::auto_ptr jednak jego użycie jest odradzane. Jego miejsce zajął unique_ptr.

Jako pierwszy zostanie omówiony inteligentny wskaźnik o nazwie unique_ptr. Z nazwy tego wskaźnika bezpośrednio wynika, że zarządzany przez niego obiekt nie może być współdzielony przez inne obiekty unique_ptr.

Zaletą inteligentnych wskaźników jest silne bezpieczeństwo ze względu na wyjątki. W sytuacji gdyby funkcja sum() z listingu 3c rzuciła wyjątkiem to kod po niej następujący nie zostałby wykonany i zaalokowana pamięć nie zostałaby nigdy zwolniona. Można oczywiście do kodu z listingu 3c dopisać obsługę sytuacji wyjątkowych, ale dla bardziej skomplikowanego przypadku dodanie obsługi wyjątków sprawi, że kod stanie się mniej przejrzysty. Na szczęście C++ przewiduje lepsze rozwiązanie takiej sytuacji. Dla kodu z listingu 3d pomimo pojawienia się wyjątku pamięć zostanie zwolniona, ponieważ po wyjściu poza zakres zostanie wywołany destruktor inteligentnego wskaźnika (obiekt automatyczny), który zwolni zaalokowaną pamięć.

A *a=new A{1,2};
a->sum();
delete a;
Listing 3c: Bardzo popularny i błędny sposób użycia dynamicznego obiektu. Zgłoszenie wyjątku przez metodę sum() powoduje wyciek zasobów.

unique_ptr<A> a{new A{1,2}};
a->sum();
Listing 3d: Przykład użycia inteligentnego wskaźnika

Inteligentny wskaźnik unique_ptr jest typem niekopiowalnym. Choć nie ma możliwości kopiowania, można przekazać własności jednego wskaźnika unique_ptr do innego wskaźnika unique_ptr na przykład poprzez wywołanie funkcji std::move(). Operacja przekazania własności dla wskaźnika unique_ptr została przedstawiona na listingu 3e.

std::unique_ptr<int> p1{new int{2}};
std::unique_ptr<int> p2 = p1; // błąd kompilacji, to nie jest typ kopiowalny
std::unique_ptr<int> p3 = std::move(p1); // OK, przekazanie własności p1 do p2
Listing 3e: Operacja przekazania własności dla wskaźnika unique_ptr.

Obiektowi klasy unique_ptr można także odebrać własność wskaźnika poprzez zawołanie na nim metody release(). Przykładowe zastosowanie tego mechanizmu prezentuje listing 3f. Kod taki często przydaje się podczas pracy ze starym kodem, nie używającym (jeszcze) wskaźników inteligentnych.

int *test()
{
 std::unique_ptr<int> p{new int{2}};
 // bezpieczne wykonanie pewnych operacji...
 return p.release();
}
Listing 3f: Oddanie własności trzymanego obiektu wołającemu, jako gołego wskaźnika.

Podobnie jak to miało miejsce w przypadku std::auto_ptr dostęp do obiektu zarządzanego przez wskaźnik unique_ptr można uzyskać poprzez operatory *, -> oraz metodę get(). Wszystkie te przypadki pokazano na listingu 3g.

std::unique_ptr<int> p{new int(2}};
*p = 4;
std::unique_ptr<A> pA{new A{1,2}};
if(pA.get()!=nullptr)
 pA->doSthOnA();
Listing 3g: Przykład dostępu do obiektu zarządzanego przez wskaźnik unique_ptr.

Brak własności kopiowania wskaźnika unique_ptr pozwala na jego bezpieczne użycie w kolekcjach STL. Użycie wskaźnika unique_ptr w kolekcji std::vector zostało przedstawione na listingu 3h.

std::vector<std::unique_ptr<int>> vc;
vc.push_back(std::unique_ptr<int>{new int{4}});
vc.push_back(std::unique_ptr<int>{new int{2}});
// losowa zamiana elementów miejscami
std::random_shuffle(vc.begin(), vc.end());
Listing 3h: Użycie inteligentnego wskaźnika unique_ptr w kontenerze std::vector.

Domyślnie wskaźnik unique_ptr zwalnia pamięć w destruktorze przy użyciu operatora delete. Nic jednak nie stoi na przeszkodzie, aby dla wskaźnika zaalokowanego z użyciem funkcji malloc inteligentny wskaźnik zwolnił pamięć z użyciem funkcji free. Wystarczy zdefiniować tzw. ,,deleter'', który zwolni pamięć w odpowiedni sposób. Definicja ,,deletera'' i jego wykorzystanie w inteligentnym wskaźniku zostało przedstawione na listingu 3i.

struct Deleter
{
 template <class T>
 void operator()(T *t)
 {
  free(t);
 }
};
// ...
double *u = (double*)malloc(sizeof(double));
std::unique_ptr<double, Deleter> up(u);
Listing 3i: Użycie wskaźnika unique_ptr dla zwolnienia pamięci przy użyciu funkcji free.

Aby sprytny wskaźnik wygodnie zarządzał tablicą nie wystarczy zastąpienie operatora delete przez delete[]. Powodem tego jest m.in. fakt, że operatory dostępu * oraz -> mają sens dla pojedynczego obiektu, a dla tablic odnoszą się tylko do pierwszego elementu, ponadto operator [] odnosi się tylko do tablic. Twórcy standardu dostarczają specjalizacji sprytnego wskaźnika unique_ptr dla tablic. Przykład użycia został przedstawiony na listingu 3j. Pamięć dla tej specjalizacji domyślnie zwalniana jest przy użyciu operatora delete[].

const int size = 4;
unique_ptr<int[]> intarr{new int[size]};
for(int i=0;i<size;++i)
 intarr[i]=10*(i+1);
Listing 3j: Przykład użycia inteligentnego wskaźnika zarządzającego tablicą

Na forach często pojawia się pytanie o niekopiowalny, inteligentny wskaźnik w C++11. Znanym przykładem takiego wskaźnika jest szablon boost::scoped_ptr (oraz jego tablicowy odpowiednik: boost::scoped_array). Okazuje się, iż i ten przypadek jest rozwiązywany przez odpowiednie użycie szablonu std::unique_ptr - wystarczy uczynić nowo powstały obiekt stałym, jak pokazano na listingu 3k, a przenoszenie własności również nie będzie możliwe.

const std::unique_ptr<int> p1{new int{2}};
const std::unique_ptr<int> p2=p1; // błąd - typ niekopiowalny
const std::unique_ptr<int> p2=std::move(p1); //błąd - obiekt niemodyfikowalny
Listing 3k: Niekopiowalny obiekt unique_ptr.

Kolejną nowością w C++11 jest inteligentny wskaźnik z licznikiem referencji - std::shared_ptr. Sam wskaźnik std::shared_ptr nie jest nowością. Jest on również dostarczany przez bibliotekę boost smart pointers jako szablon boost::shared_ptr.

Inteligentny wskaźnik shared_ptr, wprowadzony w standardzie C++11, dostarcza funkcjonalności umożliwiającej współdzielenie jednego obiektu przez wiele obiektów shared_ptr. Licznik referencji jest inkrementowany w konstruktorze wskaźnika shared_ptr, a dekrementowany w jego destruktorze. W chwili gdy licznik odniesień osiągnie wartość zero zasoby związane z obiektem zostaną zwolnione. Na listingu 3l został przedstawiony kod ilustrujący zliczanie referencji przez wskaźnik shared_ptr. Aktualny stan licznika referencji wskaźnika shared_ptr można uzyskać wywołując metodę use_count().

shared_ptr<int> p1{new int{2}}; // licznik równy 1
{
 shared_ptr<int> p2{p1}; // licznik równy 2
 {
  shared_ptr<int> p3{p1}; // licznik równy 3
 } // licznik równy 2
} // licznik równy 1
Listing 3l: Zliczanie referencji przez wskaźnik shared_ptr.

Podobnie jak w przypadku wskaźnika unique_ptr dostęp do obiektu zarządzanego przez wskaźnik shared_ptr można uzyskać poprzez operatory * , -> oraz metodę get().

Opisany wcześniej inteligentny wskaźnik shared_ptr eliminuje wiele problemów związanych z zarządzaniem pamięcią, ale nie eliminuje wszystkich. Jedną z sytuacji, z którą inteligentny wskaźnik shared_ptr sobie nie radzi jest tzw. cykliczna zależność. Powstaje ona w sytuacji, gdy inteligentny wskaźnik A wskazuje na obiekt zawierający inteligentny wskaźnik B, a B wskazuje na obiekt zawierający inteligentny wskaźnik wskazujący z powrotem na A.Sytuację taką można pokazać na przykładzie struktury (listing 3m), która przechowuje inteligentny wskaźnik na element tego samego typu. Na listingu zostały utworzone dwa inteligentne wskaźniki, które odwołują się do siebie nawzajem prowadząc w ten sposób do powstania cyklicznej zależności między nimi.

struct Node
{
 std::shared_ptr<Node> next_;
};
// ...
std::shared_ptr<Node> n1Ptr{new Node};
std::shared_ptr<Node> n2Ptr{new Node};
n1Ptr->next_ = n2Ptr;
n2Ptr->next_ = n1Ptr;
Listing 3m: Cykliczna zależność między wskaźnikami shared_ptr.

Opisana wcześniej zależność cykliczną można przerwać poprzez wywołanie metody reset() dla inteligentnego wskaźnika. By zwolnić całą pamięć, do kodu z listingu 3m należałoby dodać linię n1Ptr->next_.reset(). Jednak konieczność jawnego wołania metody do zwolnienia zasobów sprowadza nas do punktu wyjścia.

Innym sposobem na uniknięcie cyklicznej zależności między inteligentnymi wskaźnikami jest użycie tak zwanego ,,słabego wskaźnika'' - std::weak_ptr. Wskaźnik ten zapewnia ,,słabą referencję'' do wskaźnika zarządzanego przez obiekt shared_ptr. Słaby inteligentny wskaźnik nie bierze udziału w zliczaniu referencji. Dzięki temu gdy ostatni wskaźnik shared_ptr zostanie usunięty także zarządzany przez niego obiekt zostanie usunięty pomimo tego, że wskaźnik weak_ptr wciąż się do niego odwołuje. Zastosowanie słabego inteligentnego wskaźnika do przerwania cyklicznej zależności zostało przedstawione na listingu 3n.

struct WeakNode
{
 std::weak_ptr<WeakNode> next_;
};
// ...
std::shared_ptr<WeakNode> n1Ptr{new WeakNode};
std::shared_ptr<WeakNode> n2Ptr{new WeakNode};
n1Ptr->next_ = n2Ptr;
n2Ptr->next_ = n1Ptr;
Listing 3n: Brak cyklicznej zależności między wskaźnikami shared_ptr.

W celu uzyskania dostępu do wskaźnika obserwowanego przez obiekt weak_ptr należy skorzystać z funkcji lock(). Funkcja ta zwraca wskaźnik shared_ptr, który zarządza danym obiektem lub pusty wskaźnik shared_ptr jeśli zarządzany obiekt został zwolniony. Aby sprawdzić czy zarządzany obiekt jest poprawny można skorzystać z funkcji expired(). Przykład użycia funkcji lock() i expired() został przedstawiony na listingu 3o.

void show(const weak_ptr<int>& wp)
{
 shared_ptr<int> sp = wp.lock();
 if(sp.get()!=nullptr)
  cout << *sp << endl;
}
void test()
{
 weak_ptr<int> wp;
 {
  shared_ptr<int> sp{new int{7}};
  wp = sp;
  show(wp);
 }
 cout << "expired: " << boolalpha << wp.expired() << endl;
}
Listing 3o: Użycie funkcji lock() i expired()

Nieco mniej (bardziej) szablonowo

Język C++ wprowadził potężne narzędzie, które zrewolucjonizowało podejście do wielu problemów programistycznych. Początkowo było ono ,,sztucznie'' implementowane przez kompilatory za pomocą makr. Z czasem implementacje się poprawiły, a ludzie dostrzegli potencjał w nim tkwiący. Mowa oczywiście o szablonach (ang. template). Prócz oczywistych korzyści w postaci generycznych kontenerów i uniwersalnych algorytmów, szablony otworzyły także drzwi do świata praktycznego i wygodnego metaprogramowania w języku C++.

Szablony to potężne narzędzie. Niestety posiadają także wady. Dwa najczęściej stawiane zarzuty to nieczytelne komunikaty o błędach oraz wydłużony czas kompilacji. Naprzeciw pierwszemu z problemów miały wyjść tak zwane ,,koncepty''. Niestety dopracowanie ich specyfikacji wymaga znacznych nakładów czasowych, w związku z czym komitet standaryzacyjny postanowił przenieść je do kolejnego wydania języka. Drugi problem został częściowo rozwiązany poprzez dodanie możliwości definiowania tak zwanych szablonów zewnętrznych (ang. external templates) i jemu właśnie została poświęcona niniejsza część artykułu.

Czas kompilacji kodu intensywnie korzystającego z szablonów wydłuża się głównie z dwóch powodów. Pierwszym jest głębokość rekurencji rozwijania szablonów. Drugim zaś wielokrotne rozwijanie tego samego szablonu z identycznymi parametrami, ale w różnych jednostkach kompilacji. W praktyce często oba przypadki zachodzą jednocześnie.

Aby zilustrować przyrost czasu kompilacji na przykładzie napisano prostą aplikację, której kod przedstawiony został na listingu 4a. Aplikacja ta liczy (w czasie kompilacji) sumę N pierwszych elementów dowolnego ciągu S, zdefiniowanego jako struktura ze statyczną metodą get(n), pobierającą numer wyrazu do wyliczenia jako argument. Do testów napisano implementację dla ciągu arytmetycznego.

#include <iostream>
using namespace std;
// metaprogram sumujący pierwsze C wyrazów podanego ciągu
template<typename S, unsigned long C>
struct SumFirst
{
 static_assert(C>0, "oops - specialization does not work");
 static const long value=SumFirst<S,C-1>::value + S::get(C-1);
};
template<typename S>
struct SumFirst<S, 0u>
{
 static const long value=0;
};
// przykładowy ciąg arytmetyczny o pierwszym wyrazie A0 i różnicy R
template<long A0, long R>
struct Seq
{
 static constexpr long get(const unsigned long n)
 {
  return A0+R*n;
 }
};
// przykładowe użycie
int main(void)
{
 typedef Seq<5,2> MySeq;
 constexpr unsigned long count=5*1000;
 cout<<"sum["<<count<<"]: "<<SumFirst<MySeq,count>::value<<endl;
 return 0;
}
Listing 4a: Przykładowy meta-program liczący sumę N pierwszych elementów dowolnego ciągu. Podczas kompilacji GCC wymaga podania flagi -ftemplate-depth=N dla dużych wartości count.

Tak napisany program został skompilowany dla różnej liczby elementów do zsumowania. Jak widać z listingu 4a złożoność obliczeniowa jest liniowa (zakładając stały czas wyliczania elementu ciągu - O(1)) i zależy wyłącznie od liczby elementów do zsumowania. Na rysunku 4a przedstawiono wykres czasu kompilacji, dla (liniowo) rosnącej liczby elementów. Widać wyraźnie iż zależność ta jest mocno nieliniowa. Dobrą aproksymacją w prezentowanym przedziale jest podwajanie się długości czasu kompilacji co 5000 kroków zagłębienia. Mamy więc do czynienia ze złożonością wykładniczą (szczęśliwie z małym współczynnikiem stałym).

Rysunek 4a: Czas kompilacji w funkcji głębokości rozwijania szablonu.

Aby ograniczyć czas kompilacji projektu C++11 pozwala na ,,wyniesienie'' pewnej, wspólnej części obliczeń do jednej jednostki kompilacji. By tego dokonać, po ,,zwykłej'' deklaracji szablonu (np: w pliku nagłówkowym) instruujemy kompilator, by nie tworzył instancji danych konkretyzacji w tej jednostce kompilacji. Oczywiście próbując taki program zlinkować okaże się, że w przyrodzie nic nie ginie - otrzymamy błąd linkowania, gdyż nasza konkretyzacja nie doczekała się instancji (kompilator nie wygenerował dlań kodu). W tym celu jawnie instancjujemy konkretyzację w wybranej jednostce kompilacji.

Przykładowe użycie tego mechanizmu pokazano na listingu 4b. Program składa się z 3 plików. W pliku MyTemplate.hpp zadeklarowano przykładowy szablon, wypisujący na ekran podaną wartość. Plik ten zawiera deklarację blokującą tworzenie kodu dla konkretyzacji MyTemplate<std::string>. Kod dla konkretyzacji jest tworzony w pliku MyTemplate.cpp. Plik main.cpp to przykładowe zastosowanie szablonu MyTemplate.

// ==> MyTemplate.hpp <==
#ifndef INCLUDE_MYMEMPLATE_HPP_FILE
#define INCLUDE_MYMEMPLATE_HPP_FILE
#include <iostream>
#include <string>
// zwykła deklaracja szablonu
template<typename T>
struct MyTemplate
{
  void print(const T &t)
  {
    std::cout<<t<<std::endl;
  }
};
// blokujemy tworzenie kodu konkretyzacji dla std::string
extern template struct MyTemplate<std::string>;
#endif

// ==> MyTemplate.cpp <==
#include "MyTemplate.hpp"
// tworzy kod konkretyzacji dla std::string
template struct MyTemplate<std::string>;

// ==> main.cpp <==
#include <string>
#include "MyTemplate.hpp"
int main(void)
{
  {
    // wymaga linkowania z MyTemplate.o
    MyTemplate<std::string> out;
    out.print("hello world");
  }
  {
    // zawsze ok
    MyTemplate<int> out;
    out.print(42);
  }
  return 0;
}
Listing 4b: Przykładowe użycie zewnętrznie instancjonowanej specjalizacji szablonu MyTemplate, dla klasy std::string.

Szablon z listingu 4b jest obrazowy i prosty. W praktyce prawdopodobnie nie miałoby sensu tworzenie jego konkretyzacji tylko w wyszczególnionej jednostce kompilacji. By zobaczyć zyski z takiej kompilacji należałoby przyjrzeć się szablonowi, którego kod wymaga nieco więcej pracy od kompilatora. Na listingu 4c przedstawiono przykładowy szablon klasy realizującej konwersję z podanego typu do napisu (std::string), wraz z cache'owaniem. Jest to pewna optymalizacja, jeśli konwersja jest kosztowna (przykładowo dla typu double), wartości konwertowane zaś (klucze) wielokrotnie się powtarzają.

// ==> ConvCache.hpp <==
#ifndef INCLUDE_CONVCACHE_HPP_FILE
#define INCLUDE_CONVCACHE_HPP_FILE
#include <map>
#include <string>
#include <boost/lexical_cast.hpp>
template<typename TKey>
class ConvCache
{
private:
  typedef std::map<TKey, std::string> CacheMap;
public:
  const std::string &operator[](const TKey &n)
  {
    auto it=m_.find(n);
    if(it==m_.end())
    {
      const auto nStr=boost::lexical_cast<std::string>(n);
      it=m_.insert({n, nStr}).first;
    }
    return it->second;
  }
private:
  CacheMap m_;
};
#ifdef CPP0X_EXT_TEMPL
extern template class ConvCache<double>;
#endif
#endif

// ==> ConvCache.cpp <==
#include "ConvCache.hpp"
// C++03 compatible
template class ConvCache<double>;
Listing 4c: Kod realizujący cache'owaną konwersję z podanego typu do std::string.

Szablon ConvCache posiada włączalną obsługę zewnętrznie instancjonowanego szablonu poprzez makrodefinicję CPP0X_EXT_TEMPL. Dla podanego przykładu kodu stworzono skrypt generujący zadaną liczbę plików używających danej konkretyzacji szablonu (tu: ConvCache<double>). Dla zadanych liczności badano różnicę w czasie kompilacji z użyciem zewnętrznego instancjonowania (C++11) oraz bez niego (C++03). Na rysunku 4b przedstawiono zysk na czasie kompilacji w funkcji liczby plików wymagających generowania kodu skonkretyzowanego szablonu.

Rysunek 4b: Wykres zależności zysku na czasie kompilacji w zależności od liczby plików.

Wykres z rysunku 4b pokazuje silnie liniową zależność. Jest to wynik zgodny z intuicją. Widać to wyraźnie stosując nieco uproszczony model budowania. Jeśli tworzenie konkretyzacji zajmuje kompilatorowi X czasu, a identyczną operację trzeba wykonać w N jednostkach kompilacji zysk z uniknięcia tej konieczności to X*(N-1), czyli liniowy względem liczby plików.

Mówiąc o kolejnym rozszerzeniu specyfikacji szablonów warto odnieść się do innych, już poznanych, możliwości oferowanych przez C++11. Jak więc się mają zewnętrznie instancjonowane szablony do szablonów zmiennej długości? Otóż w teorii mają się dobrze. Szablony zmiennej długości także można instancjonować i deklarować jako zewnętrzne dokładnie w taki sam sposób jak ,,zwykłe szablony''. Jest to dobra wiadomość, choć trzeba uczciwie przyznać, iż w praktyce trudniej będzie o sytuacje, kiedy cecha ta przyda się programistom. Mając ,,klasyczny'' szablon nie zawsze da się przewidzieć, jakie typy będą z nim używane na tyle często, by zewnętrzne instancjonowanie miało sens. Kiedy mamy do czynienia z szablonami zmiennej długości sprawa dodatkowo się kompiluje, gdyż musimy znać nie tylko typy poszczególnych parametrów, ale także ich liczbę (do instancjonowania musimy podać pełną specjalizację szablonu).

Nieszablonowość kompilatorów

Praktyka pokazuje, że faktyczne wsparcie kompilatorów dla szablonów nie jest tak idealne jak oczekiwałby tego programista. W praktyce trafiają się błędy, a ponadto niektóre funkcjonalności nie są wcale zaimplementowane.

Poprzedni artykuł niniejszego cyklu przedstawiał szablony zmiennej długości. Dla zilustrowania problemu wsparcia dla szablonów przyjrzyjmy się listingowi 5a. Przykładowy kod generuje błąd kompilatora (funkcjonalność nie zaimplementowana) pod GCC 4.6. Clang 1.1 również nie zdał egzaminu - dopiero wersja 2.9 zrozumiała przedstawiony kod. ICC 12.0.4 w ogóle nie rozpoznał składni szablonów zmiennej długości.

$ cat main.cpp
template<typename Head, typename ...Tail>
struct VarTempl
{
 typedef typename VarTempl<Tail...>::Last Last;
};
template<typename T>
struct VarTempl<T>
{
 typedef T Last;
};
$ g++-4.6 -c -std=c++0x main.cpp
main.cpp:5:36: sorry, unimplemented: cannot expand 'Tail ...' into a fixed-length argument list
Listing 5a: Nie kompilujący się, poprawny kod C++11.

Przedstawiony meta-program to jedynie jeden z przykładów. Pisząc o C++03 sytuację dość ciekawie przedstawił Andrei Alexandrescu, w jednej ze swoich książek. Przytoczył on serię przykładów szablonów oraz ich różnych specjalizacji. Zadaniem czytelnika było odpowiedzenie, które wersje zostaną wykonane w serii przykładowych wywołań. Po podaniu prawidłowych odpowiedzi i wyjaśnieniu dlaczego tak jest, bazując na standardzie C++03, autor podsumował całość jako ,,ciekawą zabawę'' dodając jednocześnie, że ,,jeśli odpowiedziałeś poprawnie we wszystkich przypadkach istnieje spora szansa, że znasz reguły rozwiązywania lepiej niż Twój kompilator - w praktyce lepiej nie polegać na takich technikach''. Umiaru i rozwagi nigdy za wiele... :-)

Podsumowanie

W niniejszym artykule zaprezentowane zostały kolejne nowości składni języka C++11. Ważnym posunięciem komitetu standaryzacyjnego jest unifikacja sposobu tworzenia obiektów oraz wprowadzenie standardowego mechanizmu umożliwiającego inicjalizację dowolnych kolekcji w momencie tworzenia, analogicznie jak ma to miejsce dla tablic ,,w stylu C''.

Omówiono także możliwości wydzielania instancjonowania szablonów do osobnej jednostki kompilacji co znacząco wpłynęło na czas budowania systemów intensywnie używających szablonów. Jest to swojego rodzaju ukłon w stronę osób tworzących metaprogramy, jako że technika ta zdobywa coraz większe uznanie wśród programistów.

Zaprezentowane zostały także nowe wskaźniki inteligentne, wprowadzone do C++11 - aspekt kluczowy podczas programowania w C++, lecz niemal zupełnie ,,przemilczany'' przez dotychczasową bibliotekę standardową. Dzięki stosowaniu nowych podejść problemy wycieków pamięci praktycznie przestają istnieć w C++, jednocześnie nie powodując narzutów czasowych i pamięciowych charakterystycznych dla języków automatycznie zarządzających pamięcią (ang. garbage collected).

Kolejny artykuł z cyklu zostanie całkowicie poświęcony pozornie niewielkiej zmianie, jaka pojawiła się w C++11, która zrewolucjonizowała projektowanie programów w tymże języku... Mowa oczywiście o referencjach prawostronnych (ang. rvalue-reference). Umiejętnie z nich korzystając unikniemy zbędnych narzutów pamięciowych i czasowych oraz lepiej zrozumiemy działania niektórych, nowych elementów biblioteki standardowej. Stosując nowe podejście zaprezentowany zostanie mechanizm idealnego przekazywania parametrów pomiędzy wywołaniami, którego tak brakowało wcześniejszym edycjom języka.


Bartosz Szurgot
Absolwent Informatyki wydziału Informatyki i Zarządzania Politechniki Wrocławskiej. Obecnie pracuje we Wrocławskim Centrum Sieciowo-Superkomputerowym jako programista. Główne zainteresowania techniczne to: programowanie, Linux, urządzenia wbudowane oraz elektronika. W wolnym czasie tworzy oprogramowanie open-source oraz układy elektroniczne. W C++ programuje od 9 lat.
Kontakt z autorem: bartek.szurgot@baszerr.org
Strona domowa autora: http://www.baszerr.org

Mariusz Uchroński
Absolwent Elektroniki i Telekomunikacji wydziału Elektroniki Politechniki Wrocławskiej oraz pracownik Wrocławskiego Centrum Sieciowo-Superkomputerowego. Główne nurty zainteresowań technicznych to: programowanie oraz obliczenia HPC, a w szczególności programowanie GPU w CUDA i OpenCL. W C++ programuje od 5 lat.
Kontakt z autorem: mariusz.uchronski@gmail.com

Wojciech Waga
Absolwent Informatyki na wydziale Matematyki i Informatyki Uniwersytetu Wrocławskiego, obecnie słuchacz studiów doktoranckich biologii molekularnej UWr oraz pracownik Wrocławskiego Centrum Sieciowo-Superkomputerowego. Programuje w C++ od 11 lat.
Kontakt z autorem: wojciech.waga@gmail.com