C++ Core Guidelines: Type Safety by Design

Type Safety by Design steht dafür, dass Variablen immer initialisiert werden und std::variant anstelle eines Union oder Fold Expressions anstelle von va_args zum Einsatz kommen.

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

Was soll das heißen? Type Safety by Design. Es steht dafür, dass Variablen immer initialisiert werden und std::variant anstelle eines Union oder Fold Expressions anstelle von va_args zum Einsatz kommen.

Wie in meinem ersten Artikel zu Type Safety "C++ Core Guidelines: Type Safety" angekündigt, werde ich die vier fehlenden Regeln zur Type Safety nennen und zusätzliche Information hinzufügen, wenn dies notwendig ist.

Die Regeln, die durch Objekte initialisiert werden, sind recht kompliziert in C++. Hier ist ein einfaches Beispiel:

struct T1 {};
class T2{
public:
T2() {}
};

int n; // OK

int main(){
int n2; // ERROR
std::string s; // OK
T1 t1; // OK
T2 t2; // OK
}

n ist eine globale Variable. Daher wird sie mit 0 initialisiert. Diese Initialisierung gilt aber nicht für n2, denn sie ist eine lokale Variable. Wenn du hingegen eine benutzerdefinierte Variable verwendest, wird diese unabhängig davon initialisiert, ob sie global oder lokal ist.

Falls dies zu kompliziert für dich ist, gibt es eine einfache Lösung. Verwende auto. Der Compiler kann aus einem Ausdruck der Form auto a nicht erraten, welchen Typ a besitzen soll. Daher kannst du die Initialisierung von a nicht vergessen. Du musst a initialisieren:

struct T1 {};
class T2{
public:
T2() {}
};

auto n = 0;

int main(){
auto n2 = 0;
auto s = ""s;
auto t1 = T1();
auto t2 = T2();
}

In diesem Fall kann ich es kurz machen. Ich habe bereits einen Artikel "C++ Core Guidelines: Mehr Nichtregeln und Mythen" zur Initialisierung von Mitgliedern von Klassen geschrieben.

Zuerst einmal: Was ist eine Union? Sie ist ein benutzerdefinierter Datentyp, der zu einem Zeitpunkt nur einen seiner Werte besitzen kann.

"Nackte" Unions sind sehr fehleranfällig, da du auf den zugrunde liegenden Datentyp selbst Buch führen musst:

// nakedUnion.cpp

#include <iostream>

union Value {
int i;
double d;
};

int main(){

std::cout << std::endl;

Value v;
v.d = 987.654; // v holds a double
std::cout << "v.d: " << v.d << std::endl;
std::cout << "v.i: " << v.i << std::endl; // (1)

std::cout << std::endl;

v.i = 123; // v holds an int
std::cout << "v.i: " << v.i << std::endl;
std::cout << "v.d: " << v.d << std::endl; // (2)

std::cout << std::endl;

}

Die Union besitzt in der ersten Iteration einen double- und in der zweiten Iteration einen int-Wert. Falls du ein double als ein int (1) interpretierst oder ein int als ein double (2), erhältst du undefiniertes Verhalten.

  • std::variant

Eine std::variant ist hingegen eine typsichere Union, die wir seit C++17 besitzen. Eine Instanz einer std::variant besitzt einen Wert einer ihrer Datentypen. Der Datentyp kann keine Referenz, Array oder void sein. Ein default-initialisierte Union wird mit ihrem ersten Datentyp angelegt. In diesem Fall muss der erste Datentyp einen Defaultkonstruktor besitzen. Hier ist ein einfaches Beispiel von cppreference.com:

// variant.cpp

#include <variant>
#include <string>

int main(){

std::variant<int, float> v, w; // (1)
v = 12;
int i = std::get<int>(v);
w = std::get<int>(v); // (2)
w = std::get<0>(v); // (2)
w = v; // (2)

// std::get<double>(v); // error: no double in [int, float] (3)
// std::get<3>(v); // error: valid index values are 0 and 1 (4)

try{
std::get<float>(w); // (5)
}
catch (std::bad_variant_access&) {}

std::variant<std::string> v2("abc"); // (6)
v2 = "def"; // (7)

}

Ich definiere in der Zeile (1) die beiden Varianten v und w. Beide können einen int-Wert und einen float-Wert besitzen. std::get<int>(v) gibt den Wert zurück. In den Zeilen (2) siehst du drei Möglichkeiten, den Wert der Variante v der Variante w zuzuweisen. Hier gilt es aber ein paar Dinge zu beachten. Sowohl der Datentyp (Zeile 3) als auch der Index (Zeile 4) kann dazu verwendet werden, um nach dem Wert der Variante zu fragen. Dazu muss der Datentyp eindeutig und der Index gültig sein. In der Zeile (5) besitzt die Variante einen int-Wert. Daher erhalte ich eine std::bad_variant_access-Ausnahme. Wenn der Konstruktoraufruf oder die Zuweisung eindeutig ist, findet eine Konvertierung statt. Dies ist der Grund, warum ich eine std::variant<std::string> in Zeile (6) mit einem C-String erzeugen und ein C-String einer Variante (Zeile 7) zuweisen kann.

Variadische Funktionen sind Funktionen wie std::printf, die eine beliebige Anzahl an Argumenten annehmen können. Das Problem von variadischen Funktionen ist es, dass du annehmen musst, dass die richtigen Datentypen übergeben wurden. Offensichtlich ist diese Annahme sehr fehleranfällig und hängt an der Disziplin des Programmierers.

Um die implizite Gefahr von variadischen Funktionen besser zu verstehen, folgt ein kleines Beispiel.

// vararg.cpp

#include <iostream>
#include <cstdarg>

int sum(int num, ... ){

int sum{};

va_list argPointer;
va_start(argPointer, num );
for( int i = 0; i < num; i++ )
sum += va_arg(argPointer, int );
va_end(argPointer);

return sum;
}

int main(){

std::cout << "sum(1, 5): " << sum(1, 5) << std::endl;
std::cout << "sum(3, 1, 2, 3): " << sum(3, 1, 2, 3) << std::endl;
std::cout << "sum(3, 1, 2, 3, 4): " << sum(3, 1, 2, 3, 4) << std::endl; // (1)
std::cout << "sum(3, 1, 2, 3.5): " << sum(3, 1, 2, 3.5) << std::endl; // (2)

}

sum ist eine variadische Funktion. Ihr erstes Argument steht für die Anzahl der Elemente, die summiert werden sollen. Ich werde nur so viel Information zu den varargs-Makros präsentieren, sodass du das Programm verstehen kannst. Mehr Informationen gibt es wie immer auf cppreference.com.

  • va_list: besitzt die notwendigen Informationen für die folgenden Makros
  • va_start: ermöglicht den Zugriff auf die Argumente der variadischen Funktion
  • va_arg[code]: gibt den Zugriff auf das nächste Argument der variadischen Funktion
  • va_end[/code]: beendet den Zugriff auf die Argumente der variadischen Funktion

In der Zeile (1) und der Zeile (2) hatte ich einen schlechten Tag. Zuerst einmal stimmt die Anzahl der Argumente nicht, darüber hinaus habe ich einen double-Wert anstelle eines int-Werts übergeben. Die Ausgabe zeigt beide Probleme. Das letzte Argument in Zeile (1) fehlt und der double-Wert wird als int-Wert interpretiert (Zeile 2).

Diese Fehlerquelle lässt sich einfach mit Fold Expressions in C++17 überwinden:

// foldExpressions.cpp

#include <iostream>

template<class ...Args>
auto sum(Args... args) {
return (... + args);
}

int main(){

std::cout << "sum(5): " << sum(5) << std::endl;
std::cout << "sum(1, 2, 3): " << sum(1, 2, 3) << std::endl;
std::cout << "sum(1, 2, 3, 4): " << sum(1, 2, 3, 4) << std::endl;
std::cout << "sum(1, 2, 3.5): " << sum(1, 2, 3.5) << std::endl;

}

Ich gebe zu, die Funktion sum wirkt furchterregend. C++11 unterstützt Variadic Templates. Dies sind Templates, die eine beliebige Anzahl an Argumenten annehmen können. Die beliebige Anzahl wird von einem Parameter Pack gehalten, das durch eine Ellipse (...) ausgedrückt wird. Neu ist mit C++17, dass dieses Parameter Pack direkt mit einem binären Operator reduziert werden kann. Für diese Erweiterung, die auf Variadic Templates basiert, stehen Fold Expressions. Im Falle der sum-Funktion heißt dies, dass der binäre Operator + (... + args) angewandt wird. Wenn du mehr zu Fold Expressions wissen willst, kannst du meinen Artikel Fold Expressions durchlesen.

Das Programm verhält sich nun wie erwartet:

Zusätzlich zu Variadic Templates und Fold Expression gibt es einen weiteren komfortablen Weg für eine Funktion, beliebige viele Argumente eines Datentyps anzunehmen: Verwende einen Container der STL wie zum Beispiel std::vector als Argument:

// vectorSum.cpp

#include <iostream>
#include <numeric>
#include <vector>

auto sum(std::vector<int> myVec){
return std::accumulate(myVec.begin(), myVec.end(), 0);
}

int main(){

std::cout << "sum({5}): " << sum({5}) << std::endl;
std::cout << "sum({1, 2, 3}): " << sum({1, 2, 3}) << std::endl;
std::cout << "sum({1, 2, 3, 4}): " << sum({1, 2, 3, 4}) << std::endl;

}

In diesem Fall kommt eine std::initalizer_list<int> als Argument für die Funktion sum zum Einsatz. Ein std::initializer_list erlaubt es, einen std::vector in einem Rutsch zu initialisieren. Im Gegensatz zu Fold Expressions wird std::accumulate zur Laufzeit ausgeführt.

Mein nächster Artikel wird sich mit dem Profil Bounds Safety beschäftigen. Dieses Profil besteht aus vier Regeln.

Ich freue mich darauf, weitere C++-Schulungen halten zu dürfen.

Die Details zu meinen C++- und Python-Schulungen gibt es auf www.ModernesCpp.de.


()