C++ Core Guidelines: Die Standard-Bibliothek

Modernes C++  –  1 Kommentare

In den Regeln zur C++-Standard-Bibliothek geht es vor allem um Container, Strings und die IO-Streams.

Seltsam, es gibt keinen Abschnitt zu den Algorithmen der Standard Template Library (STL) in diesem Kapitel. Seltsam, denn es gibt ein Sprichwort in der C++-Community: Wenn du eine explizite Schleife verwendest, dann kennst du die Algorithmen der STL nicht.

SL.1: Use libraries wherever possible, denn das Rad neu zu erfinden ist keine gute Idee. Zusätzlich profitierst du von Früchten anderer. Das heißt, dass deine Bibliothek bereits getestet und wohldefiniert ist. Dies gilt vor allem, wenn du SL.2: Prefer the standard library to other libraries anwendest. Stelle dir vor, du stellst jemanden ein. Der Vorteil ist dann, dass er bereits die Bibliothek kennt und du ihm nicht deine Bibliotheken schulen musst. Das spart viel Zeit und Geld. Ich hatte einmal einen Kunden, der seine Infrastruktur in den Namensraum std gepackt hat. Wenn du Überraschungen liebst, tue das. Wenn nicht: SL.3: Do not add non-standard entities to namespace std.

Die nächsten Regeln zu STL-Containern werden deutlich konkreter.

Container

Die erste Regel ist recht einfach zu begründen.

SL.con.1: Prefer using STL array or vector instead of a C array

Ich nehme an, du kennst den std::vector. Einer der großen Vorteile eines std::vector gegenüber einem C-Array ist, dass der std::vector automatisch seinen Speicher verwaltet. Klar, das gilt für alle Container der Standard Template Library. Lasse mich einen genaueren Blick auf die automatische Speicherverwaltung des std::vector werfen.

  • std::vector
// vectorMemory.cpp

#include <iostream>
#include <string>
#include <vector>

template <typename T>
void showInfo(const T& t,const std::string& name){

std::cout << name << " t.size(): " << t.size() << std::endl;
std::cout << name << " t.capacity(): " << t.capacity() << std::endl;

}

int main(){

std::cout << std::endl;

std::vector<int> vec; // (1)

std::cout << "Maximal size: " << std::endl;
std::cout << "vec.max_size(): " << vec.max_size() << std::endl; // (2)
std::cout << std::endl;

std::cout << "Empty vector: " << std::endl;
showInfo(vec, "Vector");
std::cout << std::endl;

std::cout << "Initialised with five values: " << std::endl;
vec = {1,2,3,4,5};
showInfo(vec, "Vector"); // (3)
std::cout << std::endl;

std::cout << "Added four additional values: " << std::endl;
vec.insert(vec.end(),{6,7,8,9});
showInfo(vec,"Vector"); // (4)
std::cout << std::endl;

std::cout << "Resized to 30 values: " << std::endl;
vec.resize(30);
showInfo(vec,"Vector"); // (5)
std::cout << std::endl;

std::cout << "Reserved space for at least 1000 values: " << std::endl;
vec.reserve(1000);
showInfo(vec,"Vector"); // (6)
std::cout << std::endl;

std::cout << "Shrinke to the current size: " << std::endl;
vec.shrink_to_fit(); // (7)
showInfo(vec,"Vector");

}

Um mir Schreibarbeit zu sparen, habe ich die kleine Funktion showInfo geschrieben. Diese Funktion gibt zu einem Vektor seine Größe und Kapazität aus. Die Größe eines Vektors ist die Anzahl seiner Elemente und seine Kapazität die Anzahl der Elemente, die er besitzen kann ohne Speicher neu anzufordern. Daher ist die Kapazität eines Vektors größer als seine Größe. Die Größe eines Vektors kannst du mit der Methode resize anpassen, dessen Kapazität mit der Methode reserve.

Nun aber das Programm von Anfang bis zum Ende. In Zeile 1 erzeuge ich einen Vektor. Dann stelle ich die maximale Anzahl der Elemente dar (Zeile 2), die ein Vektor besitzen kann. Nach jeder weiteren Operationen auf dem std::vector gebe ich dessen Größe und Kapazität aus. Das trifft für die Initialisierung des Vektors (Zeile 3), für das Hinzufügen von vier Elementen (Zeile 4) zum Vektor, das Vergrößern des Vektors auf 30 Elemente (Zeile 5) und das Reservieren von zusätzlichem Speicherplatz für 1000 Elemente (Zeile 6) zu. Selbst das Verkleinern des Vektors auf seine tatsächliche Größe wird mit der Methode shrink_to_fit (Zeile 7) seit C++11 unterstützt.

Bevor ich die Ausgabe des Programms auf Linux vorstelle, möchte ich meine wichtigsten Beobachtungen zusammenfassen.

  1. Die Anpassung der Größe und Kapazität des Vektors geschieht automatisch. Ich verwende in diesem Programm keine expliziten Aufrufe zur Speicherallokation oder -deallokation.
  2. Durch die Methodenausführung cont.resize(n) erhält der Vektor cont neue, Default-initialisierte Elemente, wenn n > cont.size() ist.
  3. Durch die Methodenausführung cont.reserve(n) wird neuer Speicher für mindestens n Elemente für cont reserviert, wenn n > const.capacity() ist.
  4. Der Aufruf der Methode shrink_to_fit ist nicht bindend. Das heißt, die C++-Laufzeit muss die Kapazität des Vektors nicht an seine Größe anpassen. Bei all meinen Anwendungen von shrink_to_fit mit GCC, Clang oder cl.exe wurde der unnötige Speicher immer freigegeben.

Aber welcher Unterschied besteht zwischen einem C-Array und einem C++-Arrray?

  • std::array

std::array verbindet das Beste aus zwei Welten. Zum einen besitzt es die Größe und Effizienz eines C-Arrays, zum anderen bietet es das Interface eines std::vector an.

Mein kleines Programm vergleicht die Speichereffizienz eins C-Arrays, eines C++Arrays (std::array) und eines std::vector:

// sizeof.cpp

#include <iostream>
#include <array>
#include <vector>


int main(){

std::cout << std::endl;

std::cout << "sizeof(int)= " << sizeof(int) << std::endl;

std::cout << std::endl;

int cArr[10]= {1,2,3,4,5,6,7,8,9,10};

std::array<int,10> cppArr={1,2,3,4,5,6,7,8,9,10};

std::vector<int> cppVec={1,2,3,4,5,6,7,8,9,10};

std::cout << "sizeof(cArr)= " << sizeof(cArr) << std::endl; // (1)

std::cout << "sizeof(cppArr)= " << sizeof(cppArr) << std::endl; // (2)

// (3)

std::cout << "sizeof(cppVec) = " << sizeof(cppVec) + sizeof(int)*cppVec.capacity() << std::endl;
std::cout << " = sizeof(cppVec): " << sizeof(cppVec) << std::endl;
std::cout << " + sizeof(int)* cppVec.capacity(): " << sizeof(int)* cppVec.capacity() << std::endl;

std::cout << std::endl;

}

Sowohl das C-Array (Zeile 1) als auch das C++-Array (Zeile 2) benötigen 40 Byte. Das entspricht der Größe sizeof(int) * 10. Im Gegensatz dazu benötigt der std::vector 24 zusätzliche Bytes (Zeile 3), um seine Daten auf dem freien Speicher zu verwalten.

Dies war der C-Anteil eines std::array, aber das std::array hat viel mit einem std::vector gemein. Das bedeutet insbesondere, dass ein std::array seine Größe kennt. Damit gehören fehleranfällige Interfaces wie das folgende der Vergangenheit an und besitzen ein starkes Geschmäkle (code-smell).

void bad(int* p, int count){
...
}

int myArray[100] = {0};
bad(myArray, 100);

// -----------------------------

void good(std::array<int, 10> arr){
...
}

std::array<int, 100> myArray = {0};
good(myArray);

Wenn du ein C-Array als ein Funktionsargument verwendet, schmeißt du fast alle Typinformation weg und übergibst das C-Array als ein Zeiger auf sein erstes Element. Dies ist sehr fehleranfällig, denn du musst die Anzahl der Elemente des C-Arrays als weiteres Argument angeben. Das ist nicht nötig, wenn deine Funktion ein std::array<int, 100> annimmt.

Falls dir die Funktion good nicht generisch genug ist, kannst du ein Template einsetzen.

template <typename T>
void foo(T& arr){

arr.size(); // (1)

}


std::array<int, 100> arr{};
foo(arr);

std::array<double, 20> arr2{};
foo(arr2);

Da ein std::array seine Größe kennt, kannst du danach in der Zeile 1 fragen.

Wie geht's weiter?

Die nächsten zwei Regeln zu Containern sind recht interessant. Im nächsten Artikel gebe ich dir eine Antwort auf die Frage: Wann soll ich welchen Container verwenden?