C++ Core Guidelines: Regeln zu Anweisungen und zur Arithmetik

Hier nun der Abschlussartikel zu den Regeln bei Anweisungen. Dazu gibt es die sehr wichtigen Regeln zur Arithmetik. Werden diese nicht eingehalten, lauert undefiniertes Verhalten

In Pocket speichern vorlesen Druckansicht 17 Kommentare lesen
Lesezeit: 9 Min.
Von
  • Rainer Grimm

Heute schließe ich mit den verbleibenden Regeln zu Anweisungen ab. Dazu gibt es die sehr wichtigen Regeln zur Arithmetik. Werden diese nicht eingehalten, lauert undefiniertes Verhalten.

Vier Regeln zu Anweisungen sind noch auf meiner To-do-Liste:

Die erste Regel ist sehr offensichtlich.

ES.84: Don’t (try to) declare a local variable with no name

Das Deklarieren einer lokalen Variable ohne Name besitzt keine Auswirkung. Mit dem abschließenden Strichpunkt verliert die Variable ihre Gültigkeit:

void f()
{
lock<mutex>{mx}; // Bad
// critical region
}

Typischerweise unterdrückt der Optimierer das Erzeugen temporärer Objekten, wenn diese nicht das beobachtbare Verhalten des Programms betreffen. Dies ist die sogenannte as-if-Regel. Falls der Konstruktor beobachtbares Verhalten wie die Veränderungen des globales Zustands des Programms besitzt, darf der Optimierer die Erzeugung des temporären Objektes nicht unterdrücken.

ES.85: Make empty statements visible

Um ehrlich zu sein, verstehe ich den Grund für diese Regel nicht. Warum sollte ich leere Anweisungen verwenden? Für mich sind beide Beispiele nur schlechter Programmierstil:

for (i = 0; i < max; ++i);   // BAD: the empty statement is easily overlooked
v[i] = f(v[i]);

for (auto x : v) { // better
// nothing
}
v[i] = f(v[i]);

ES.86: Avoid modifying loop control variables inside the body of raw for-loops

Klar, das ist in zweifacher Hinsicht ein sehr schlechter Programmierstil. Zuerst einmal, solltest du keine nackten Schleifen verwenden und stattdessen Algorithmen der Standard Template Library einsetzen. Darüber hinaus gilt, dass du die Kontrollvariable im Körper der nackten Schleife nicht modifizieren solltest. Hier kommt der schlechte Programmierstil:

for (int i = 0; i < 10; ++i) {
//
if (/* something */) ++i; // BAD
//
}

bool skip = false;
for (int i = 0; i < 10; ++i) {
if (skip) { skip = false; continue; }
//
if (/* something */) skip = true; // Better: using two variable for two concepts.
//
}

Insbesondere die zweite Schleife ist schwer zu durchschauen, da sie unter der Decke zwei verschachtelte, abhängige Schleifen enthält.

ES.87: Don’t add redundant == or != to conditions

Schuldig im Sinne der Anklage. In meinen ersten Jahren als professioneller C++-Entwickler habe ich oft überflüssige == oder != in Bedingungen verwendet. Natürlich habe ich mittlerweile meine Praxis angepasst.

// p is not a nullptr
if (p) { ... } // good
if (p != nullptr) { ... } // redundant

// p is a nullptr
if (!p) { ... } // good
if (p == 0) { ... } // redundant

for (string s; cin >> s;) // the istream operator returns bool
v.push_back(s);

Dies waren die Regeln zu Anweisungen. Weiter geht es mit den Regeln zur Arithmetik. Hier sind die ersten sieben Regeln:

Ehrlich gesagt, ich habe oft nicht so viel zu den Regeln hinzuzufügen. Der Vollständigkeit (und Wichtigkeit) halber will ich sie in diesem Artikel kurz und bündig vorstellen.

ES.100: Don’t mix signed and unsigned arithmetic

Wenn du vorzeichenbehaftete (signed) und vorzeichenlose (unsigned) Arithmetik vermischst, erhältst du nicht das erwartete Ergebnis:

#include <iostream>

int main(){

int x = -3;
unsigned int y = 7;

std::cout << x - y << std::endl; // 4294967286
std::cout << x + y << std::endl; // 4
std::cout << x * y << std::endl; // 4294967275
std::cout << x / y << std::endl; // 613566756

}

Sowohl der GCC, der Clang als auch der Microsoft-Compiler produzierten das gleiche Ergebnis.

ES.101: Use unsigned types for bit manipulation

Der Grund für die Regel ist sehr naheliegend. Bitoperationen auf vorzeichenbehafteten Datentypen sind implementation-defined.

ES.102: Use signed types for arithmetic

Zuerst einmal sollten bei der Arithmetik vorzeichenbehaftete Datentypen verwendet werden. Darüber hinaus sollten keine vorzeichenbehafteten und vorzeichenlosen Datentypen zum Einsatz kommen. Sonst ist der Überraschungseffekt groß:

#include <iostream>

template<typename T, typename T2>
T subtract(T x, T2 y){
return x - y;
}

int main(){

int s = 5;
unsigned int us = 5;
std::cout << subtract(s, 7) << '\n'; // -2
std::cout << subtract(us, 7u) << '\n'; // 4294967294
std::cout << subtract(s, 7u) << '\n'; // -2
std::cout << subtract(us, 7) << '\n'; // 4294967294
std::cout << subtract(s, us + 2) << '\n'; // -2
std::cout << subtract(us, s + 2) << '\n'; // 4294967294


}

ES.103: Don’t overflow and ES.104: Don’t underflow

Beide Regeln lassen sich schön zusammen beschreiben. Der Effekt eines Überlaufs oder eines Unterlaufs ist derselbe: memory corruption und undefined behaviour. Ein einfacher Test mit einem int-Array zeigt das Unheilspotenzial. Wie lange läuft das folgende Programm, indem jenseits der Array-Grenzen gelesen und geschrieben wird?

// overUnderflow.cpp

#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;
}

}

Beunruhigend lange. Das Programm schreibt jeden hundertsten Array-Wert auf std::cout.

ES.105: Don’t divide by zero

Falls dein Programm abstürzen soll, hilft eine einfache Division durch 0. Das Teilen durch 0 kann aber in logischen Ausdrücken OK sein.

bool res = false and (1/0);

Da das Ergebnis des Ausdrucks (1/0) nicht für das Gesamtergebnis relevant ist, wird es nicht ausgewertet. Diese Technik nennt sich short circuit evaluation und ist ein Spezialfall der Bedarfsauswertung.

ES.106: Don’t try to avoid negative values by using unsigned

Wende keine vorzeichenlosen Datentypen an, falls du negative Werte vermeiden willst. Die Konsequenzen sind weitreichend. Die Charakteristik der Arithmetik ändert sich dadurch und dein Programm wird anfällig für Fehler rund um die vorzeichenbehaftete/vorzeichenlose Arithmetik.

Hier sind zwei Beispiel aus den Guidelines, die vorzeichenbehaftete und vorzeichenlose Arithmetik vermischen:

unsigned int u1 = -2;   // Valid: the value of u1 is 4294967294
int i1 = -2;
unsigned int u2 = i1; // Valid: the value of u2 is 4294967294
int i2 = u2; // Valid: the value of i2 is -2


unsigned area(unsigned height, unsigned width) { return height*width; }
// ...
int height;
cin >> height;
auto a = area(height, 2); // if the input is -2 a becomes 4294967292

Wie die Guidelines bemerken, gibt es eine interessante Beziehung. Wenn ein vorzeichenlose int mit einer -1 initialisiert wird, wird diese zu größten vorzeichenlosen int.

Nun aber zu dem interessanteren Fall. Das Verhalten der Arithmetik unterscheidet sich bei vorzeichenbehafteten und vorzeichenlosen Datentypen.

Los geht es mit einem einfachen Programm:

// modulo.cpp

#include <cstddef>
#include <iostream>

int main(){

std::cout << std::endl;

unsigned int max{100000};
unsigned short x{0}; // (2)
std::size_t count{0};
while (x < max && count < 20){
std::cout << x << " ";
x += 10000; // (1)
++count;
}

std::cout << "\n\n";
}

Der entscheidende Punkt des Programms ist es, dass die fortwährende Addition auf x in Zeile (1) keinen Überlauf erzeugt. Die fortwährende Addition erzeugt eine Modulo-Operation, wenn der Wertebereich von x endet. Der Grund ist, dass x eine vorzeichenlose unsigned short (1) ist.

// overflow.cpp

#include <cstddef>
#include <iostream>

int main(){

std::cout << std::endl;

int max{100000};
short x{0}; // (2)
std::size_t count{0};
while (x < max && count < 20){
std::cout << x << " ";
x += 10000; // (1)
++count;
}

std::cout << "\n\n";
}

Eine kleine Modifikation des Programms modulo.cpp besitzt weitreichende Konsequenzen. Die vorzeichenlose Variable x (2) bekam ein Vorzeichen. Die Auswirkung ist, dass das Programm jetzt einen Überlauf erzeugt.

Die entscheidenen Stelle der Ausgabe ist rot markiert;

Jetzt gilt es noch eine brennende Frage zu beantworten: Wie lässt sich der Überlauf erkennen? Ersetze einfach die fehlerhafte Zuweisung x += 1000 durch einen Ausdruck mit geschweiften Klammern: x = {x + 1000}, Der Unterschied ist, dass der Compiler prüft, ob verengenden Konvertierung (narrowing conversion) vorliegt. Hier ist die Ausgabe des GCC:

Die Ausdrücke (x += 1000) und (x = {x + 1000}) sind mit der Performanzbrille betrachtet nicht identisch. Der zweite Ausdruck kann eine temporären Wert für x + 1000 erzeugen. In dem konkreten Fall machte mein Optimierer einen erstklassigen Job und er erzeugt dieselben Assembler-Anweisungen.

Ich bin bereits fast fertig mit den Regeln zur Arithmetik. Das heißt, dass ich mich in meinem nächsten Artikel bereits den Regeln zur Performanz widmen werde. ()