W poprzednim rozdziale omówiliśmy szablony funkcji (11.6 -- Szablony funkcji), które pozwalają nam uogólniać funkcje do pracy z wieloma różnymi typami danych. Chociaż jest to świetny początek na drodze do programowania uogólnionego, nie rozwiązuje wszystkich naszych problemów. Przyjrzyjmy się przykładowi takiego problemu i zobaczmy, co szablony mogą jeszcze dla nas zrobić.
Szablony i klasy kontenerów
W lekcji na temat 23.6 — Klasy kontenerów, nauczyłeś się używać kompozycji do implementacji klas zawierających wiele instancji innych klas. Jako przykład takiego kontenera przyjrzeliśmy się klasie IntArray. Oto uproszczony przykład tej klasy:
#ifndef INTARRAY_H
#define INTARRAY_H
#include <cassert>
class IntArray
{
private:
int m_length{};
int* m_data{};
public:
IntArray(int length)
{
assert(length > 0);
m_data = new int[length]{};
m_length = length;
}
// We don't want to allow copies of IntArray to be created.
IntArray(const IntArray&) = delete;
IntArray& operator=(const IntArray&) = delete;
~IntArray()
{
delete[] m_data;
}
void erase()
{
delete[] m_data;
// We need to make sure we set m_data to 0 here, otherwise it will
// be left pointing at deallocated memory!
m_data = nullptr;
m_length = 0;
}
int& operator[](int index)
{
assert(index >= 0 && index < m_length);
return m_data[index];
}
int getLength() const { return m_length; }
};
#endifChociaż ta klasa zapewnia łatwy sposób tworzenia tablic liczb całkowitych, co jeśli chcemy utworzyć tablicę liczb podwójnych? Używając tradycyjnych metod programowania, musielibyśmy stworzyć zupełnie nową klasę! Oto przykład DoubleArray, klasy tablicowej używanej do przechowywania wartości podwójnych.
#ifndef DOUBLEARRAY_H
#define DOUBLEARRAY_H
#include <cassert>
class DoubleArray
{
private:
int m_length{};
double* m_data{};
public:
DoubleArray(int length)
{
assert(length > 0);
m_data = new double[length]{};
m_length = length;
}
DoubleArray(const DoubleArray&) = delete;
DoubleArray& operator=(const DoubleArray&) = delete;
~DoubleArray()
{
delete[] m_data;
}
void erase()
{
delete[] m_data;
// We need to make sure we set m_data to 0 here, otherwise it will
// be left pointing at deallocated memory!
m_data = nullptr;
m_length = 0;
}
double& operator[](int index)
{
assert(index >= 0 && index < m_length);
return m_data[index];
}
int getLength() const { return m_length; }
};
#endifChociaż lista kodów jest długa, zauważysz, że te dwie klasy są prawie identyczne! W rzeczywistości jedyną istotną różnicą jest zawarty typ danych (int vs double). Jak zapewne się domyślasz, jest to kolejny obszar, w którym szablony można dobrze wykorzystać, uwalniając nas od konieczności tworzenia klas powiązanych z jednym konkretnym typem danych.
Tworzenie klas szablonowych działa prawie identycznie jak tworzenie funkcji szablonowych, więc będziemy kontynuować na przykładach. Oto nasza klasa tablicowa w wersji szablonowej:
Array.h:
#ifndef ARRAY_H
#define ARRAY_H
#include <cassert>
template <typename T> // added
class Array
{
private:
int m_length{};
T* m_data{}; // changed type to T
public:
Array(int length)
{
assert(length > 0);
m_data = new T[length]{}; // allocated an array of objects of type T
m_length = length;
}
Array(const Array&) = delete;
Array& operator=(const Array&) = delete;
~Array()
{
delete[] m_data;
}
void erase()
{
delete[] m_data;
// We need to make sure we set m_data to 0 here, otherwise it will
// be left pointing at deallocated memory!
m_data = nullptr;
m_length = 0;
}
// templated operator[] function defined below
T& operator[](int index); // now returns a T&
int getLength() const { return m_length; }
};
// member functions defined outside the class need their own template declaration
template <typename T>
T& Array<T>::operator[](int index) // now returns a T&
{
assert(index >= 0 && index < m_length);
return m_data[index];
}
#endifJak widać, ta wersja jest prawie identyczna z wersją IntArray, z tą różnicą, że dodaliśmy deklarację szablonu i zmieniliśmy zawarty w niej typ danych z int na T.
Zauważ, że funkcję operator[] zdefiniowaliśmy także poza deklaracją klasy. Nie jest to konieczne, ale nowi programiści zazwyczaj potykają się, próbując zrobić to po raz pierwszy ze względu na składnię, więc przykład jest pouczający. Każda szablonowa funkcja członkowska zdefiniowana poza deklaracją klasy wymaga własnej deklaracji szablonu. Należy również zauważyć, że nazwa klasy tablicy z szablonem to Array<T>, a nie Array — Array będzie odnosić się do nieszablonowej wersji klasy o nazwie Array, chyba że wewnątrz klasy zostanie użyta Array. Na przykład konstruktor kopiujący i operator przypisania kopiowania użyły Array zamiast Array<T>. Gdy nazwa klasy jest używana bez argumentów szablonu wewnątrz klasy, argumenty są takie same, jak argumenty bieżącej instancji.
Oto krótki przykład użycia powyższej klasy tablicy z szablonem:
#include <iostream>
#include "Array.h"
int main()
{
const int length { 12 };
Array<int> intArray { length };
Array<double> doubleArray { length };
for (int count{ 0 }; count < length; ++count)
{
intArray[count] = count;
doubleArray[count] = count + 0.5;
}
for (int count{ length - 1 }; count >= 0; --count)
std::cout << intArray[count] << '\t' << doubleArray[count] << '\n';
return 0;
}Ten przykład wyświetla następujący komunikat:
11 11.5 10 10.5 9 9.5 8 8.5 7 7.5 6 6.5 5 5.5 4 4.5 3 3.5 2 2.5 1 1.5 0 0.5
Klasy szablonów są tworzone w taki sam sposób, jak funkcje szablonu — kompilator tworzy na żądanie kopię z szablonu, zastępując parametr szablonu rzeczywistym typem danych, którego potrzebuje użytkownik, a następnie kompiluje kopię. Jeśli nigdy nie użyjesz klasy szablonowej, kompilator nawet jej nie skompiluje.
Klasy szablonowe idealnie nadają się do implementacji klas kontenerowych, ponieważ wysoce pożądane jest, aby kontenery działały na wielu różnych typach danych, a szablony umożliwiają to bez duplikowania kodu. Chociaż składnia jest brzydka, a komunikaty o błędach mogą być tajemnicze, klasy szablonowe są naprawdę jedną z najlepszych i najbardziej przydatnych funkcji C++.
Podział klas szablonowych
Szablon nie jest klasą ani funkcją — jest to szablon używany do tworzenia klas lub funkcji. Jako taki nie działa w taki sam sposób, jak normalne funkcje lub klasy. W większości przypadków nie stanowi to większego problemu. Istnieje jednak jeden obszar, który często powoduje problemy dla programistów.
W przypadku klas innych niż szablony powszechną procedurą jest umieszczenie definicji klasy w pliku nagłówkowym, a definicji funkcji składowych w pliku kodu o podobnej nazwie. W ten sposób definicje funkcji składowych są kompilowane jako oddzielny plik projektu. Jednak w przypadku szablonów to nie działa. Rozważmy następujące kwestie:
Array.h:
#ifndef ARRAY_H
#define ARRAY_H
#include <cassert>
template <typename T> // added
class Array
{
private:
int m_length{};
T* m_data{}; // changed type to T
public:
Array(int length)
{
assert(length > 0);
m_data = new T[length]{}; // allocated an array of objects of type T
m_length = length;
}
Array(const Array&) = delete;
Array& operator=(const Array&) = delete;
~Array()
{
delete[] m_data;
}
void erase()
{
delete[] m_data;
// We need to make sure we set m_data to 0 here, otherwise it will
// be left pointing at deallocated memory!
m_data = nullptr;
m_length = 0;
}
// templated operator[] function defined below
T& operator[](int index); // now returns a T&
int getLength() const { return m_length; }
};
// Definition of Array<T>::operator[] moved into Array.cpp below
#endifArray.cpp:
#include "Array.h"
// member functions defined outside the class need their own template declaration
template <typename T>
T& Array<T>::operator[](int index) // now returns a T&
{
assert(index >= 0 && index < m_length);
return m_data[index];
}main.cpp:
#include <iostream>
#include "Array.h"
int main()
{
const int length { 12 };
Array<int> intArray { length };
Array<double> doubleArray { length };
for (int count{ 0 }; count < length; ++count)
{
intArray[count] = count;
doubleArray[count] = count + 0.5;
}
for (int count{ length - 1 }; count >= 0; --count)
std::cout << intArray[count] << '\t' << doubleArray[count] << '\n';
return 0;
}Powyższy program skompiluje się, ale spowoduje błąd linkera:
undefined reference to `Array<int>::operator[](int)'
Podobnie jak w przypadku szablonów funkcji, kompilator utworzy instancję szablonu klasy tylko wtedy, gdy szablon klasy zostanie użyty (np. jako typ obiektu takiego jak intArray) w jednostce translacyjnej. Aby wykonać instancję, kompilator musi zobaczyć zarówno pełną definicję szablonu klasy (a nie tylko deklarację), jak i określone potrzebne typy szablonów.
Pamiętaj również, że C++ kompiluje pliki indywidualnie. Podczas kompilacji pliku main.cpp zawartość nagłówka Array.h (w tym definicja klasy szablonu) jest kopiowana do pliku main.cpp. Kiedy kompilator zauważy, że potrzebujemy dwóch instancji szablonu, Array<int> i Array<double>, utworzy je i skompiluje jako część jednostki translacyjnej main.cpp. Ponieważ operator[] funkcja składowa ma deklarację, kompilator zaakceptuje jej wywołanie, zakładając, że zostanie zdefiniowana gdzie indziej.
Gdy Array.cpp jest kompilowany osobno, zawartość nagłówka Array.h jest kopiowana do Array.cpp, ale kompilator nie znajdzie w Array.cpp żadnego kodu, który wymagałby utworzenia instancji szablonu klasy Array lub Array<int>::operator[] szablonu funkcji - więc nie będzie utwórz instancję czegokolwiek.
Tak więc, gdy program zostanie połączony, otrzymamy błąd linkera, ponieważ main.cpp wywołał Array<int>::operator[] ale ta funkcja szablonu nigdy nie została utworzona!
Istnieje sporo sposobów obejścia tego problemu.
Najłatwiej jest po prostu umieścić cały kod klasy szablonu w pliku nagłówkowym (w tym przypadku umieść zawartość Array.cpp do Array.h, poniżej klasy). W ten sposób, gdy #uwzględnisz nagłówek, cały kod szablonu będzie w jednym miejscu. Zaletą tego rozwiązania jest prostota. Wadą jest to, że jeśli klasa szablonu jest używana w wielu plikach, powstanie wiele lokalnych instancji klasy szablonu, co może wydłużyć czas kompilacji i łączenia (linker powinien usunąć zduplikowane definicje, więc nie powinien nadymać pliku wykonywalnego). Jest to nasze preferowane rozwiązanie, chyba że czas kompilacji lub łączenia zaczyna stanowić problem.
Jeśli uważasz, że umieszczenie kodu Array.cpp w nagłówku Array.h powoduje, że nagłówek jest zbyt długi/nieporządny, alternatywą jest przeniesienie zawartości Array.cpp do nowego pliku o nazwie Array.inl (.inl oznacza inline), a następnie umieść Array.inl na dole nagłówka Array.h (wewnątrz osłony nagłówka). Daje to taki sam wynik, jak umieszczenie całego kodu w nagłówku, ale pomaga zachować trochę lepszą organizację.
Wskazówka
Jeśli użyjesz metody .inl, a następnie otrzymasz błąd kompilatora dotyczący zduplikowanych definicji, najprawdopodobniej kompilator kompiluje plik .inl jako część projektu tak, jakby był to plik kodu. Powoduje to, że zawartość pliku .inl jest kompilowana dwukrotnie: raz, gdy kompilator kompiluje plik .inl i raz, gdy kompilowany jest plik .cpp zawierający plik .inl. Jeśli plik .inl zawiera jakiekolwiek funkcje (lub zmienne), które nie są wbudowane, naruszymy zasadę jednej definicji. Jeśli tak się stanie, będziesz musiał wykluczyć plik .inl z kompilacji w ramach kompilacji.
Wykluczenie pliku .inl z kompilacji można zwykle wykonać, klikając prawym przyciskiem myszy plik .inl w widoku projektu, a następnie wybierając właściwości. Ustawienie będzie gdzieś tam. W Visual Studio ustaw „Wyklucz z kompilacji” na „Tak”. W Code::Blocks odznacz „Skompiluj plik” i „Połącz plik”.
Inne rozwiązania obejmują #włączenie plików .cpp, ale nie zalecamy ich ze względu na niestandardowe użycie #include.
Inną alternatywą jest zastosowanie podejścia z trzema plikami. Definicja klasy szablonu znajduje się w nagłówku. Funkcje składowe klasy szablonu są umieszczane w pliku kodu. Następnie dodajesz trzeci plik, który zawiera uniknąć utworzone instancje klas, których potrzebujesz:
templates.cpp:
// Ensure the full Array template definition can be seen
#include "Array.h"
#include "Array.cpp" // we're breaking best practices here, but only in this one place
// #include other .h and .cpp template definitions you need here
template class Array<int>; // Explicitly instantiate template Array<int>
template class Array<double>; // Explicitly instantiate template Array<double>
// instantiate other templates herePolecenie „template class” powoduje, że kompilator jawnie tworzy instancję klasy szablonu. W powyższym przypadku kompilator utworzy szablonowo definicje Array<int> i Array<double> w pliku templates.cpp. Inne pliki kodu, które chcą używać tych typów, mogą obejmować Array.h (aby zadowolić kompilator), a linker będzie łączyć te jawne definicje typów z template.cpp.
Ta metoda może być bardziej wydajna (w zależności od tego, jak twój kompilator i linker obsługują szablony i zduplikowane definicje), ale wymaga utrzymywania pliku templates.cpp dla każdego programu.

