Was haben alle Container der Standard Template Library gemein? Sie besitzen einen Typ-Parameter Allocator, für den per Default std::allocator zum Einsatz kommt. Die Aufgabe des Speicherbeschaffers (allocator) besteht darin, den Lebenszyklus seiner Elemente zu verwalten. Das bedeutet, Speicher für die Elemente anzufordern und freizugeben und diese zu initialisieren und zu destruieren.
Auch wenn ich in diesem Artikel von Containern der Standard Template Library spreche, treffen meine Aussagen auch auf den std::string zu. Der Einfachheit halber bleibe ich aber bei dem Begriff Container.
Was zeichnet std::allocator aus?
Zum einen macht es einen deutlichen Unterschied, ob er ein Element wie ein std::vector oder ein Paar wie ein std::map allokiert.
template<
class T,
class Allocator = std::allocator<T>
> class vector;
template<
class Key,
class T,
class Compare = std::less<Key>,
class Allocator = std::allocator<std::pair<const Key, T> >
> class map;
Zum anderem benötigt ein Speicherbeschaffer wie std::allocator einen reichen Satz an Attributen, Methoden und Funktionen um seine Arbeit ausführen zu können.
Das Interface
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
// Attributes
value_type T
pointer T*
const_pointer const T*
reference T&
const_reference const T&
size_type std::size_t
difference_type std::ptrdiff_t
propagate_on_container_move_assignment std::true_ty
rebind template< class U > struct rebind { typedef allocator<U> other; };
is_always_equal std::true_type
// Methods
constructor
destructor
address
allocate
deallocate
max_size
construct
destroy
// Functions
operator==
operator!=
|
Die wichtigsten Mitglieder von std::allocator<T> möchte ich kurz im Schnelldurchlauf vorstellen.
Von den Attributen ist das innere Klassen-Template rebind (Zeile 10) besonders interessant. Damit ist es möglich, einen std::allocator vom Typ T auf einen Typ U umzubiegen. Das Herz von std::allocate sind die zwei Methoden allocate (Zeile 17) und deallocate (Zeile 18). Diese beiden Methoden verwalten den Speicher, in dem das Objekt mit construct (Zeile 20) initialisiert, bzw. mit destroy (Zeile 21) destruiert wird. Die Methode max_size (Zeile 19) gibt die maximale Anzahl an Objekten vom Typ T zurück, für die std::allocator Speicher anfordern kann.
Natürlich lässt sich std::allocator auch direkt verwenden.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
|
// allocate.cpp
#include <memory>
#include <iostream>
#include <string>
int main(){
std::cout << std::endl;
std::allocator<int> intAlloc;
std::cout << "intAlloc.max_size(): " << intAlloc.max_size() << std::endl;
int* intArray = intAlloc.allocate(100);
std::cout << "intArray[4]: " << intArray[4] << std::endl;
intArray[4] = 2011;
std::cout << "intArray[4]: " << intArray[4] << std::endl;
intAlloc.deallocate(intArray, 100);
std::cout << std::endl;
std::allocator<double> doubleAlloc;
std::cout << "doubleAlloc.max_size(): " << doubleAlloc.max_size() << std::endl;
std::cout << std::endl;
std::allocator<std::string> stringAlloc;
std::cout << "stringAlloc.max_size(): " << stringAlloc.max_size() << std::endl;
std::string* myString = stringAlloc.allocate(3);
stringAlloc.construct(myString, "Hello");
stringAlloc.construct(myString + 1, "World");
stringAlloc.construct(myString + 2, "!");
std::cout << myString[0] << " " << myString[1] << " " << myString[2] << std::endl;
stringAlloc.destroy(myString);
stringAlloc.destroy(myString + 1);
stringAlloc.destroy(myString + 2);
stringAlloc.deallocate(myString, 3);
std::cout << std::endl;
}
|
In dem Programm kommen drei Speicherbeschaffer zum Einsatz. Einer für int (Zeile 11), einer für double (Zeile 26) und einer für std::string (Zeile 31). Jeder der drei Speicherbeschaffer kennt die Anzahl der Elemente, die er maximal allokieren kann (Zeile 14, 27 und 32).
Nun zum Speicherbeschaffer für int: std::allocator<int> intAlloc (Zeile 11). Mit intAlloc lässt sich ein int-Array von 100 Elementen anlegen (Zeile 14). Der Zugriff auf das fünfte Element in Zeile 16 ist nicht definiert, da dies noch nicht initialisiert wurde. Das ändert sich in Zeile 20. Durch den Aufruf intAlloc.deallocate(intArray, 100) (Zeile 22) gebe ich den Speicher wieder frei.
Der Umgang mit dem std::string-Speicherbeschaffer ist schon deutlich aufwändiger. Die drei stringAlloc.construct-Aufrufe in den Zeilen 36 - 28 stoßen drei Konstruktoraufrufe für std::string an. Die drei stringAlloc.destroy-Aufrufe (Zeile 42 - 44) führen zu den entsprechenden Destrukoraufrufen. Zum Abschluss (Zeile 34) wird der Speicher von myString wieder freigegeben.
Zum Abschluss noch die Ausgabe des Programms.
C++17
Mit C++17 vereinfacht sich das Interface von std::allocator deutlich. Viele der Mitglieder besitzen den Status deprecated.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
// Attributes
value_type T
propagate_on_container_move_assignment std::true_ty
is_always_equal std::true_type
// Methods
constructor
destructor
allocate
deallocate
// Functions
operator==
operator!=
|
Die entscheidende Frage habe ich aber noch gar nicht beantwortet.
Warum benötigen die Container einen Speicherbeschaffer?
Darauf gibt es zwei Antworten.
- Die Container sollen unabhängig vom verwendeten Intel Memory Model sein. So verwendet das Intel Memory Model auf x86 Architekturen die sechs verschiedenen Varianten tiny, small, medium, compact, large und huge. Ich will hier explizit darauf hinweisen. Ich spreche vom Intel Memory Model und nicht vom Speichermodell (eng. memory model) als Grundlage für Multithreading.
- Die Container können die Speicherallokation bzw. Speicherfreigabe von der Initialisierung bzw. Destruktion der Elemente trennen. So fordert der Aufruf vec.reserve(n) eines std::vector vec nur Speicher für mindestens n Elemente an. Der Konstruktor für jedes einzelne Element wird aber nicht ausgeführt. (Sven Johannsen)
- Die Speicherallokation der Container lässt sich exakt an die eigenen Bedürfnisse anpassen. So sind die Default-Speicherbeschaffer für das relativ seltene allokieren großer Speicherbereiche optimiert, die unter der Decke gerne die C-Funktion malloc verwenden. Da kann ein Speicherbeschaffer, der sich aus einem bereits allokierten Speicherbereich bedient, einen deutlichen Performanzboost bringen. Aus Sicherheitsgründen ist es aber auch eine Option, den Speicher bereits zur Compilezeit zu reservieren oder den Speicherbeschaffer mit zusätzlichen Debugginginformationen zu versehen.
Wie geht's weiter?
Welche Strategien gilt es für das Anfordern von Speicher? Dieser Frage will ich in dem nächsten Artikel genauer nachgehen.
Go to Leanpub/cpplibrary "What every professional C++ programmer should know about the C++ standard library". Hole dir dein E-Book. Unterstütze meinen Blog.
Weiterlesen...