Aus Linux-Magazin 01/2023

Modernes C++ in der Praxis – Folge 66

© bwylezich / 123RF.com

Greifen Sie auf ein Element außerhalb eines Containers der Standard Template Library zu, lauert undefiniertes Verhalten. Das macht verlässliche Aussagen über das Programm unmöglich.

Zuallererst einmal: Was ist eigentlich ein Bounds-Fehler? Ein solcher tritt auf, wenn Sie über die Elemente eines Containers hinaus lesen oder schreiben. Diesbezüglich sind die C++ Core Guidelines sehr konkret: “Avoid bounds errors” (SL.con.3 [1]). Die Guidelines beginnen mit dem abschreckenden Beispiel aus Listing 1, das die unsicheren C-Funktionen zum Füllen und Vergleichen eines »std::array« verwendet.

Listing 1

Unsicherer Zugriff

std::array<int, 10> a, b;
// BAD, enthält einen Längenfehler
// (length = 10 * sizeof(int))
std::memset(a.data(),0,10);
// BAD, enthält einen Längenfehler
// (length = 10 * sizeof(int))
std::memcmp(a.data(),b.data(),10);
sizeof(int))

Wie es die Kommentare zu dem Code bereits sagen: Die Länge des C-Arrays ist nicht 10, sondern »10 * sizeof(int)«. Die offensichtliche Lösung des Problems: Verwenden Sie stattdessen wie in Listing 2 die Funktionalität von »std::array«.

Listing 2

Sicherer Zugriff

std::array<int, 10> a;
std::array<int, 10> b;
std::array<int, 10> c{};
a.fill(0);
std::fill(b.begin(), b.end(), 0);
if ( a == b ){
 [...]
}

Im konkreten Fall erfolgt keine Initialisierung der »std::array«-Instanzen »a« und »b«. Im Gegensatz dazu sorgt die Aggregatinitialisierung dafür, dass alle Werte der »std::array«-Instanz »c« auf 0 gesetzt werden. Die Zeilen 4 und 5 erledigen dasselbe explizit für alle Werte von »a« und »b«. Darüber hinaus lassen sich die C++-Arrays in Zeile 6 direkt vergleichen.

Greift man auf einen Container der Standard Template Library (STL [2]) außerhalb seines Bereichs zu, hängt das Ergebnis davon ab, ob es ich um einen sequenziellen oder einen assoziativen Container handelt. Erfolgt der Zugriff auf die Elemente eines sequenziellen Containers, ist das Ergebnis sehr beunruhigend: undefiniertes Verhalten. Das bedeutet, dass sich über das Verhalten des Programms keine verlässlichen Aussagen mehr machen lassen. Was bedeutet dies für ein C-Array als einfachsten sequenziellen Container, den wir in C++ besitzen?

Sequenzielle Container

Die Auswirkungen eines Über- oder Unterlaufs gestalten sich ähnlich: Entweder wird eine beliebige Speicherstelle gelesen oder überschrieben. Machen wir als Beispiel einen einfachen Test mit einem »int«-Array: Wie lange wird das Programm in Listing 3 wohl laufen? Abbildung 1 bringt es direkt auf den Punkt: Viel zu lange! Das Programm schreibt jeden 100. Array-Eintrag nach »std::cout«. Das sieht nicht gerade vertrauenerweckend aus. Welche Ausgabe produziert das Programm, wenn ein sequenzieller Container der STL zum Einsatz kommt?

Abbildung 1: &Uuml;berlauf und Unterlauf eines C-Arrays.

Abbildung 1: Überlauf und Unterlauf eines C-Arrays.

Listing 3

Über- und Unterlauf

#include <cstddef>
#include <iostream>
int main(){
  int a[0];
  int n{};
  while (true){
    if (!(n % 100)){
      std::cout << "a[" << n << "] = " << a[n] << ", a[" << -n << "] = " << a[-n] << "\n";
    }
    a[n] = n;
    a[-n] = -n;
    ++n;
  }
}

Für »std::array«, »std::vector«, »std::deque« und »std::string« gibt es den Index-Operator. Da ein »std::string« sich sehr ähnlich verhält wie ein sequenzieller Container, ist er Bestandteil dieser Aufzählung. Das heißt, alle vier genannten Container unterstützen den wahlfreien Zugriff und geben einen Iterator dafür zurück. Um Sie nicht zu Tode zu langweilen, beschränken sich die nächsten Experimente auf »std::array« und »std::vector«.

<C>std::array<C>

Listing 4 zeigt das leicht modifizierte Programm aus Listing 3 für ein »std::array«. Das Ergebnis ist ernüchternd: Der Index-Operator für ein C++-Array in Abbildung 2 ist genauso unsicher wie der für ein C-Array (Abbildung 1).

Abbildung 2: &Uuml;ber- und Unterlauf eines C++-Arrays.

Abbildung 2: Über- und Unterlauf eines C++-Arrays.

Listing 4

Über- und Unterlauf (C++-Array)

#include <array>
#include <iostream>
int main(){
  std::array<int, 1> a;
  int n{};
  while (true){
    if (!(n % 100)){
      std::cout << "a[" << n << "] = " << a[n] << ", a[" << -n << "] = " << a[-n] << "\n";
    }
    a[n] = n;
    a[-n] = -n;
    ++n;
  }
}

<C>std::vector<C>

Vielleicht naht die Hoffnung in der Form eines »std::vector«? In Listing 5 kommen Variationen von Listing 3 und Listing 4 für »std::vector« zum Einsatz.

Listing 5

Über- und Unterlauf (std::vector)

#include <vector>
#include <iostream>
int main(){
  std::vector<int> a{1};
  int n{};
  while (true){
    if (!(n % 100)){
      std::cout << "a[" << n << "] = " << a[n] << ", a[" << -n << "] = " << a[-n] << "\n";
    }
    a[n] = n;
    a[-n] = -n;
    ++n;
  }
}

Da der »std::vector« seine Objekte auf dem Heap erzeugt und nicht auf dem Stack wie das C- und C++-Array, dauert es eine ganze Weile, bis das Programm fehlschlägt. Die Screenshots in Abbildung 3 und Abbildung 4 zeigen den Anfang und das Ende des Unter- und Überlaufs. Die assoziativen Container »std::map« und »std::unordered_map« unterstützen den Index-Operator.

Abbildung 3: Unterlauf eines &raquo;std::vector&laquo;.

Abbildung 3: Unterlauf eines »std::vector«.

Abbildung 4: &Uuml;berlauf eines &raquo;std::vector&laquo;.

Abbildung 4: Überlauf eines »std::vector«.

Assoziative Container

Wie verhält es sich, wenn ein nicht existierender Schlüssel in einer »std::map« oder einer »std::unordered_map« zum Einsatz kommt? Listing 6 hilft dabei, diese Frage zu beantworten.

Listing 6

Nicht existierender Schlüssel

#include <iostream>
#include <map>
#include <unordered_map>
#include <string>
int main(){
  std::cout << std::boolalpha << '\n';
  std::map<std::string, int> myMap;
  std::unordered_map<std::string, bool> myUnorderedMap;
  std::cout << "myMap[DoesNotExist]: " << myMap["DoesNotExist"] << '\n';
  std::cout << "myUnorderedMap[DoesNotExist]: " << myUnorderedMap["DoesNotExist"] << '\n';
}

Im Falle des assoziativen Containers bleibt das Verhalten wohldefiniert, auch wenn der Schlüssel nicht verfügbar ist. Als Wert wird der Vorgabewert des Values zurückgegeben. Dieser wird zusammen mit dem Schlüssel in den assoziativen Container eingetragen. Daher benötigt der Wert einen Default-Konstruktor. Das Ergebnis zeigt Abbildung 5: »myMap« gibt in Zeile 9 den Wert »0« zurück, »myUnorderedMap« in Zeile 10 den Wert »false«.

Abbildung 5: Zugriff auf einen nicht existierenden Schl&uuml;ssel in einer &raquo;std::map&laquo; und einer &raquo;std::unordered_map&laquo;.

Abbildung 5: Zugriff auf einen nicht existierenden Schlüssel in einer »std::map« und einer »std::unordered_map«.

Die wesentliche Frage des Leitfadens bleibt davon jedoch unberührt: Wie lassen sich Bounds-Fehler vermeiden? Die Rettung naht in Form der Member-Funktion »at«.

Die Rettung

Im Falle des C-Arrays existiert keine Möglichkeit, einen Bounds-Fehler zu erkennen. Für die C++-Container einschließlich »std::string« gibt es die Methode »at«, mit der sich die Grenzen überprüfen lassen. Die sequenziellen C++-Container lösen eine Ausnahme des Typs »std::out_of_range« aus, wenn man auf ein nicht vorhandenes Element des Containers zugreift. Listing 7 demonstriert das eindrucksvoll anhand eines »std::string«.

Listing 7

Sicherer String-Zugriff

#include <stdexcept>
#include <iostream>
#include <string>
int main(){
  std::cout << '\n';
  std::string str("1123456789");
  str.at(0) = '0';
  std::cout << str << '\n';
  std::cout << "str.size(): " << str.size() << '\n';
  std::cout << "str.capacity() = " << str.capacity() << '\n';
  try {
    str.at(12) = 'X';
  }
  catch (const std::out_of_range& exc) {
    std::cout << exc.what() << '\n';
  }
  std::cout << '\n';
}

Das Setzen des ersten Zeichens der Zeichenkette »str« auf »0« (Zeile 7) geht in Ordnung, aber der Zugriff auf ein Zeichen außerhalb ihrer Elemente ist ein Fehler. Das gilt auch, wenn der Zugriff innerhalb der Kapazität der Zeichenkette erfolgt, aber außerhalb der Größe von »std::string«. Die Größe eines »std::string« entspricht der Anzahl der Elemente, die er besitzt (Zeile 9). Seine Kapazität ist gleich der Anzahl der Elemente, die ein »std::string« besitzen kann, ohne zusätzlichen Speicher anfordern zu müssen (Zeile 10). Die Fehlermeldung des GCC-Compilers in Abbildung 6 fällt sehr spezifisch aus.

Abbildung 6: Die sehr spezifische Fehlermeldung des GCC-Compilers.

Abbildung 6: Die sehr spezifische Fehlermeldung des GCC-Compilers.

Auch die assoziativen Container »std::map« und »std::unordered_map« bieten eine sichere »at«-Variante für den Zugriff an. Listing 8 stellt diese vor, wozu es den Index-Operator aus Listing 6 durch die Member-Funktion »at« ersetzt. Die C++-Laufzeitumgebung beendet die Ausführung des Programms mit einer »std::out_of_range«-Ausnahme (Abbildung 7).

Listing 8

Sicherer Container-Zugriff

#include <iostream>
#include <map>
#include <unordered_map>
#include <string>
int main(){
  std::cout << std::boolalpha << '\n';
  std::map<std::string, int> myMap;
  std::unordered_map<std::string, bool> myUnorderedMap;
  std::cout << "myMap[DoesNotExist]: " << myMap["DoesNotExist"] << '\n';
  std::cout << "myUnorderedMap[DoesNotExist]: " << myUnorderedMap["DoesNotExist"] << '\n';
}
Abbildung 7: Die C++-Runtime beendet das Ausf&uuml;hren des Programms mit einer Ausnahme.

Abbildung 7: Die C++-Runtime beendet das Ausführen des Programms mit einer Ausnahme.

Eine entscheidende Frage bleibt jedoch unbeantwortet: Warum wird der Index-Operator der Member-Funktion »at« bei den sequenziellen und assoziativen Containern vorgezogen? Die Antwort ist einfach und gilt in C++ fast immer: Performance. Das Überprüfen der Container-Grenzen zur Laufzeit erfordert zusätzlichen Aufwand, den der Index-Operator nicht zu schultern hat.

Ausblick

Gut vier Jahre hat sich die Artikelserie “Modernes C++ in der Praxis” mit den C++ Core Guidelines [3] beschäftigt und die wichtigsten ihrer Regeln vorgestellt. Dazu gibt es auch ein passendes Buch [4].

Nun ist es an der Zeit, einen tieferen Blick in den aktuellen C++20-Standard zu wagen. C++20 verändert die Art und Weise, wie modernes C++ geschrieben wird, ähnlich fundamental wie vor ihm C++98 und C++11. Diese Revolution ist hauptsächlich den großen Vier geschuldet: Concepts, Ranges, Modulen und Coroutinen. (jlu)

DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 4 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Inline Feedbacks
Alle Kommentare anzeigen
Nach oben