C++ Core Guidelines: Bounds Safety

Modernes C++  –  6 Kommentare

Der heutige Artikel beschäftigt sich mit dem zweiten Profil der C++ Core Guidelines:
Bounds Safety. Dessen Ziel ist es, dass keine Operation über die Grenzen des allokierten Speichers hinausgreift.

Das Profil nennt zuerst die zwei Feinde der Bounds Safety: Zeigerarithmetik und Indexoperationen. Zusätzlich sollst du nur Zeiger verwenden, die auf ein Objekt verweisen. Dies schließt natürlich Arrays aus. Um die Bounds Safety abzurunden, ist es notwendig, die Regeln zur Type Safety und zur Lifetime Safety einzuhalten. Zur Type Safety habe ich bereits zwei Artikel geschrieben: C++ Core Guidelines: Type Safety und C++ Core Guidelines: Type Safety by Design. Mit der Lifetime Safety werde ich mich im nächsten Artikel befassen.

Bounds Safety

Die Bounds Safety besteht aus vier Regeln:

  • Bounds.1: Don't use pointer arithmetic
  • Bounds.2: Only index into arrays using constant expressions
  • Bounds.3: No array-to-pointer decay
  • Bounds.4: Don't use standard-library functions and types that are not bounds-checked

Die vier Regen zur Bounds Safety beziehen sich auf drei Regeln der C++ Core Guidelines. Wie in meinen vorherigen Artikeln werde ich daher Hintergrundinformationen hinzufügen, falls dies notwendig ist.

Bounds.1: Don't use pointer arithmetic, Bounds.2: Only index into arrays using constant expressions und Bounds.3: No array-to-pointer decay

Die Begründung der drei Regeln lässt sich auf drei Do's reduzieren: Übergebe nur Zeiger auf einzelne Elemente, wende nur einfache Zeigerarithmetik an und verwende std::span. Die erste Regel möchte ich gerne negativ formulieren: Übergib keine Zeiger auf Arrays. Ich vermute, du kennst std::span nicht? std::span<T> repräsentiert einen kontinuierlichen Speicherbereich, den er nicht besitzt. Dieser Bereich kann ein Array, ein Zeiger mit einer Länge oder ein std::vector sein.

Die Guidelines bringen es deutlich auf den Punkt: "Complicated pointer manipulation is a major source of errors." Warum sollte uns das interessieren? Klar, unser Legacy-Code ist voll mit Altlasten wie im folgendem Beispiel:

void f(int* p, int count)
{
if (count < 2) return;

int* q = p + 1; // BAD

int n = *p++; // BAD

if (count < 6) return;

p[4] = 1; // BAD

p[count - 1] = 2; // BAD

use(&p[0], 3); // BAD
}

int myArray[100]; // (1)

f(myArray, 100), // (2)

Das dominante Problem dieses Codebeispiels ist es, dass der Aufruf der Funktion die richtige Länge des C-Arrays übergeben muss. Falls nicht, resultiert es in undefiniertes Verhalten.

Denke über die letzten zwei Zeilen (1) und (2) ein paar Sekunden nach. Diese beginnen mit einem Array, von dem seine Typinformation entfernt wird, indem es als Argument der Funktion f verwendet wird. Dieser Prozess nennt sich "array to poiner decay" und ist der Grund für viele Fehler. Vielleicht hatte der Autor dieser Zeilen einen schlechten Tag und er zählte die Anzahl der Elemente falsch oder die Größe des C-Arrays ändert sich nachträglich. Egal, das Ergebnis ist immer dasselbe: undefiniertes Verhalten. Dieselbe Argumentation gilt natürlich auch für einen C-String.

Was sollen wir das tun? Wir sollten den richtigen Datentyp verwenden. Die Guidelines empfehlen den Container std::span. Hier ist ein kleiner Codeschnipsel:

void f(span<int> a) // BETTER: use span in the function declaration
{
if (a.length() < 2) return;

int n = a[0]; // OK

span<int> q = a.subspan(1); // OK

if (a.length() < 6) return;

a[4] = 1; // OK

a[count - 1] = 2; // OK

use(a.data(), 3); // OK
}

Gut! std::span prüft zur Laufzeit seine Grenzen.

Ich höre bereits Bedenken. Wir setzen noch kein C++20 ein. Die Funktion f lässt sich direkt mithilfe des Containers std::array und seiner Method std::array::at neu formulieren. Hier ist sie:

// spanVersusArray.cpp

#include <algorithm>
#include <array>

void use(int*, int){}

void f(std::array<int, 100>& a){

if (a.size() < 2) return;

int n = a.at(0);

std::array<int, 99> q;
std::copy(a.begin() + 1, a.end(), q.begin()); // (1)

if (a.size() < 6) return;

a.at(4) = 1;

a.at(a.size() - 1) = 2;

use(a.data(), 3);
}

int main(){

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

f(arr);

}

Der std::array::at-Operator prüft zur Laufzeit seine Grenzen. Falls pos >= size, wirft er eine std::out_of_range-Ausnahme. Wer das Programm spanVersusArray.cpp sorgfältig studiert, dem fallen zwei Einschränkungen auf. Zuerst ist der Ausdruck (1) deutlich wortreicher als die entsprechende std::span-Version. Darüber hinaus ist die Länge des std::array Bestandteil der Signatur der Funktion. Das ist sehr schlecht. In diesem Fall kann die Funktion nur mit dem Container std::array<int, 100> verwendet werden. Damit ist auch die Prüfung der Array-Größe im Funktionskörper überflüssig.

Zu unserer Rettung besitzt C++ Templates. Daher ist es einfach, diese Einschränkungen zu überwinden und trotzdem typsicher zu bleiben:

// at.cpp

#include <algorithm>
#include <array>
#include <deque>
#include <string>
#include <vector>

template <typename T>
void use(T*, int){}

template <typename T>
void f(T& a){

if (a.size() < 2) return;

int n = a.at(0);

std::array<typename T::value_type , 99> q; // (5)
std::copy(a.begin() + 1, a.end(), q.begin());

if (a.size() < 6) return;

a.at(4) = 1;

a.at(a.size() - 1) = 2;

use(a.data(), 3); // (6)
}

int main(){

std::array<int, 100> arr{};
f(arr); // (1)

std::array<double, 20> arr2{};
f(arr2); // (2)

std::vector<double> vec{1, 2, 3, 4, 5, 6, 7, 8, 9};
f(vec); // (3)

std::string myString= "123456789";
f(myString); // (4)

// std::deque<int> deq{1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// f(deq);

}

Jetzt lässt sich die Funktion f auf std::arrays' beliebiger Länge (1) und beliebigem zugrunde liegenden Datentyp (2) anwenden. Darüber hinaus kann sie mit einem std::vector (3) oder einem std::string (4) aufgerufen werden. Diesen Containern ist gemeinsam, dass ihre Daten einem kontinuierlichen Speicherbereich gespeichert werden. Das gilt nicht für std::deque, und somit schlägt der Aufruf a.data() im Ausdruck (6) fehl. Ein std::deque ist eine Art doppelt verkettete Liste kleiner Speicherbereiche.

Der Ausdruck T::value_type (5) erlaubt es, den zugrunde liegenden Typ eines Containers zu erhalten. T ist ein sogenannter Dependent Typ, den T ist ein Typ-Parameter des Funktions-Templates f. Das ist der Grund, dass ich dem Compiler unter die Arme greife und ihm einen Hinweis geben muss, dass er in diesem Fall T::value_type als Typ interpretieren soll: typename T::value_type.

Bounds.4: Don't use standard-library functions and types that are not bounds-checked

Ich habe bereits einen Artikel C++ Core Guidelines: Greife nicht über den Container hinaus geschrieben. Diese Artikel gibt viele Hintergrundinformationen zu der Regel.

Wie geht's weiter?

Der Name des dritten Profils ist Lifetime Safety. Dieses Profil, dem ich mich im nächsten Artikel widme, besteht aus einer Regel: Verwende keinen ungültigen Zeiger.