1. Nullable-Design
Fast jeder „reale“ Code läuft früher oder später in die Situation: „dieser Wert existiert nicht“. Der Nutzer hat eine nicht vorhandene id eingegeben, die Suche fand nichts, der String ist leer, der Parser konnte keine Zahl lesen. Und hier schaltet der Anfänger oft in den altbewährten Modus: „ich gebe -1 zurück“ oder „ich gebe einen leeren String zurück, und der Aufrufer kümmert sich“.
Das Problem ist, dass -1 kein Fehlen ist, sondern ein gewöhnlicher Wert aus der Menge der int-Werte. Und ein leerer String ist ebenfalls ein Wert. Wenn man „kein Ergebnis“ und „Ergebnis ist null/leer“ mischt, beginnt der Code ein Doppelleben zu führen: von außen sieht alles normal aus, aber im Inneren entstehen viele versteckte Absprachen.
Heute betrachten wir drei Ansätze, mit denen man üblicherweise «kann fehlen» ausdrückt, und lernen, wie man den Ansatz nach Semantik und nicht nach Laune wählt.
Fehlt ein Objekt oder fehlt ein Ergebniswert?
Bevor Sie T*, std::optional oder ein „magischer Wert“ wählen, müssen Sie sich die richtige Frage stellen. Sie lautet: „Existiert das Objekt, zu dem ich Zugriff geben möchte, möglicherweise nicht, oder kann das berechnete Ergebniswert fehlen?“
Wenn Sie ein Element in einer Kollektion suchen und „Zugriff auf das gefundene Objekt“ geben möchten — das ähnelt der Situation „das Objekt kann fehlen“. Wenn nichts gefunden wird, existiert das Objekt praktisch nicht.
Wenn Sie versuchen, eine Zahl aus einem String zu parsen, suchen Sie nicht nach einem „Objekt“, sondern versuchen, einen Wert zu erhalten, der fehlschlagen kann. Hier passt das Modell „Ergebniswert kann fehlen“ deutlich besser.
Diese Unterscheidung wirkt philosophisch — bis zum ersten großen Projekt, in dem die Hälfte der Bugs daraus resultiert, dass „Werte“ mit „Fehlen“ verwechselt wurden.
2. Pointer (T* / const T*) + nullptr
Pointer sind der erste natürliche Weg, „kann nicht vorhanden sein“ auszudrücken. Ein Pointer in C++ kann die Adresse eines Objekts enthalten oder nullptr, was wörtlich „zeigt auf nichts“ bedeutet. Das lässt sich sehr schön mit dem Vertrag verbinden: ein Objekt kann fehlen.
Ein wichtiger stilistischer Punkt: Ein Pointer als Rückgabewert wird meist so gelesen, als würden Sie Zugriff auf ein bereits existierendes Objekt zurückgeben, nicht „einen neuen Wert erzeugen“. Also ist T* eher eine „Referenz, die leer sein kann“.
Mini‑Beispiel: Buch per id suchen und Pointer zurückgeben
Wir setzen unser kleines Konsolenprojekt fort (nehmen wir ein Mini‑Verzeichnis „LibraryLite“, in dem wir Bücher in einem std::vector halten). Ein Buch ist eine einfache Struktur.
#include <string>
struct Book {
int id{};
std::string title;
};
Jetzt die Suchfunktion:
#include <vector>
Book* FindBookById(std::vector<Book>& books, int id) {
for (Book& b : books) {
if (b.id == id) return &b;
}
return nullptr;
}
Die Signatur sagt schon alles: „ich kann die Adresse eines Buchs zurückgeben, oder nullptr, falls kein Buch vorhanden ist“.
Verwendung:
#include <iostream>
#include <vector>
int main() {
std::vector<Book> books{{1, "Dune"}, {2, "1984"}};
if (Book* b = FindBookById(books, 2)) {
std::cout << b->title << "\n"; // 1984
} else {
std::cout << "Not found\n";
}
}
Beachten Sie: die Prüfung if (b) bringt Sie fast dazu, das Fehlen zu behandeln. Fast — denn nichts hindert Sie daran, später einfach -> zu verwenden und ein Abenteuer zu erleben.
const T* als „gefunden, aber nicht veränderbar“
Wenn die Funktion das gefundene Objekt nicht verändern darf, drückt man das mit const aus:
#include <vector>
const Book* FindBookById(const std::vector<Book>& books, int id) {
for (const Book& b : books) {
if (b.id == id) return &b;
}
return nullptr;
}
Das ist der typische „Zugriffsvertrag“: „ich gebe Zugriff zurück, aber nur zum Lesen“.
Wann Pointer die beste Wahl sind
Pointer sind eine ausgezeichnete Wahl, wenn Sie Zugriff auf ein Objekt zurückgeben, das bereits irgendwo lebt, und „leer“ bedeutet „Objekt existiert nicht“. Deshalb werden Pointer oft in Funktionen wie „Element finden“, „Einstellung finden“, „Entität per Schlüssel finden“ verwendet.
3. std::optional<T>: Wert kann fehlen
std::optional<T> ist ein Wrapper‑Typ, der entweder einen Wert vom Typ T oder den Zustand „kein Wert“ enthält. Keine Magie, keine Exceptions: einfach ein ehrliches Modell «hat/hat nicht Ergebnis».
Konzeptionell ist optional sehr praktisch, weil er nicht so tut, als wäre -1 das Fehlen. Er zwingt Sie dazu, im Code die Verzweigung explizit zu sehen: entweder gibt es einen Wert, oder nicht. In der Standardbibliothek gibt es sogar std::nullopt für diesen „leeren“ Zustand.
Mini‑Beispiel: int aus String ohne Exceptions parsen und optional zurückgeben
Angenommen, der Nutzer gibt die Buch‑id als Text ein. Wir wollen die Zahl robust parsen. Semantisch suchen wir kein „Objekt“, sondern wollen einen Wert (int) bekommen, der ausbleiben kann.
#include <charconv>
#include <optional>
#include <string_view>
std::optional<int> ParseInt(std::string_view s) {
int value = 0;
auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), value);
if (ec != std::errc{}) return std::nullopt;
if (ptr != s.data() + s.size()) return std::nullopt;
return value;
}
Hier geben wir ehrlich std::nullopt zurück, wenn das Parsen fehlschlägt. (Übrigens ist std::from_chars praktisch, weil es keine Exceptions wirft, sondern über einen Fehlercode informiert. Das passt ideal zu optional.)
Verwendung:
#include <iostream>
int main() {
if (auto id = ParseInt("42")) {
std::cout << "id=" << *id << "\n"; // id=42
} else {
std::cout << "bad id\n";
}
}
Beachten Sie den Stil: if (auto id = ParseInt(...)) liest sich wie „prüfe, ob ein Wert vorhanden ist“. Das ist fast wie if (p) bei Pointern, aber mit anderer Semantik: hier gibt es keine Adresse und keine Lebenszeit — hier ist es „Wert als Ergebnis“.
Mini‑Beispiel: Index der gefundenen Buches als optional<size_t> zurückgeben
Manchmal brauchen Sie kein Objekt, sondern eine Position. Eine Position ist wieder ein Wert, kein Objekt, und „keine Position“ lässt sich bequem mit optional ausdrücken.
#include <cstddef>
#include <optional>
#include <vector>
std::optional<std::size_t> FindBookIndexById(const std::vector<Book>& books, int id) {
for (std::size_t i = 0; i < books.size(); ++i) {
if (books[i].id == id) return i;
}
return std::nullopt;
}
Verwendung:
#include <iostream>
#include <vector>
int main() {
std::vector<Book> books{{1, "Dune"}, {2, "1984"}};
if (auto pos = FindBookIndexById(books, 99)) {
std::cout << books[*pos].title << "\n";
} else {
std::cout << "No such book\n"; // No such book
}
}
Wann optional die beste Wahl ist
optional ist ideal, wenn „kann fehlen“ sich auf das Ergebnis einer Berechnung bezieht und nicht auf ein Objekt. Parsen, Berechnungen, Indexsuche, Mittelwertberechnung, das Auslesen von Einstellungen — all das drückt man oft besser mit optional aus als mit einem Pointer.
Und noch wichtig: optional liest sich gut als Signal: „der Autor der Funktion erwartet, dass Sie das behandeln“. Das ist eine kleine Typendisziplin.
4. Sentinel: „spezieller Wert gleichen Typs“
Sentinel ist der Ansatz „wir vereinbaren, dass ein bestimmter Wert ‚kein Ergebnis‘ bedeutet“. Das klassische Beispiel in C++ ist std::string::npos. Die Methode find gibt eine Position (size_t) zurück, und wenn die Teilzeichenfolge nicht gefunden wird, liefert sie npos.
Warum existiert das? Historisch sind manche APIs so gebaut: sie geben einen normalen Typ zurück, und man „packt“ einen Sonderfall in einen besonderen Wert. Das ist billig, schnell und erfordert keinen zusätzlichen Wrapper.
Aber der Preis ist: Fehlen ist nicht im Typ sichtbar, sondern in einer Konvention versteckt. Wenn Sie die Überprüfung vergessen, stürzt das Programm nicht sofort (was manchmal sogar gnädiger wäre), sondern beginnt seltsame Dinge zu tun.
Beispiel: find und npos richtig verwenden
#include <iostream>
#include <string>
int main() {
const std::string title = "The Lord of the Rings";
const std::size_t pos = title.find("Lord");
if (pos == std::string::npos) {
std::cout << "Not found\n";
} else {
std::cout << "Found at " << pos << "\n"; // Found at 4
}
}
Hier ist alles korrekt: wir haben den Sentinel geprüft und verwenden die Position erst danach.
Beispiel: Sentinel in ein klares bool-API einpacken
Wenn Sie sich nicht ständig an npos erinnern wollen, können Sie eine Funktion schreiben, die ein bool zurückgibt:
#include <string>
bool Contains(const std::string& text, const std::string& what) {
return text.find(what) != std::string::npos;
}
Jetzt muss der aufrufende Code gar nichts vom Sentinel wissen — er sieht ein ehrliches bool.
Wann Sentinel eine vernünftige Wahl ist
Sentinel ist geeignet, wenn:
- Der Typ eine offiziell akzeptierte Konstante hat (wie std::string::npos), und nicht „-1 auf gut Glück“.
- Dieser Wert nicht mit einem normalen Ergebnis verwechselt werden kann.
- Sie die Prüfung nahe bei der Verwendung halten können (oder sie in eine Funktion kapseln), damit sie nicht verloren geht.
5. Auswahlkriterien: optional vs pointer vs sentinel
Man würde gern eine universelle Ein-Zeilen-Regel haben, aber das Leben mag keine Einzeiler. Daher machen wir eine praktische Tabelle: was jeder Ansatz ausdrückt, welche Prüfungen im Code zu erwarten sind und wo man sich typischerweise verletzt.
| Ansatz | Was „leer“ bedeutet | Was Sie wirklich zurückgeben | Typische Prüfung | Wo am praktischsten |
|---|---|---|---|---|
|
|
Zugriff auf Objekt (Adresse) | |
„Objekt finden“, „Objekt kann fehlen“ |
|
|
Wert-Ergebnis | |
„Wert erhalten“, „Ergebnis kann fehlen“ |
| sentinel (npos, -1, etc.) | „spezieller Wert“ | normaler Typ (Index/Code) | |
historische APIs, Positionen in Strings, Fälle mit allgemein akzeptierter Konstante |
Wenn Sie „Zugriff auf ein Objekt“ zurückgeben, denken Sie an Pointer. Wenn Sie „Wert als Ergebnis einer Berechnung“ zurückgeben, denken Sie an optional. Wenn Sie einen normalen Typ zurückgeben und es einen allgemein akzeptierten Sentinel gibt, verwenden Sie ihn, aber prüfen Sie möglichst nah bei der Verwendung oder kapseln Sie die Prüfung in eine kleine Funktion.
Zur Veranschaulichung hier ein kleines Entscheidungsdiagramm:
flowchart TD
A["Muss ausgedrückt werden: 'kann fehlen'"] --> B{"Geben wir Zugriff auf ein Objekt zurück?"}
B -->|Ja| C["Geben T* / const T* und nullptr zurück"]
B -->|Nein| D{"Geben wir einen Wert als Ergebnis zurück?"}
D -->|Ja| E["Geben std::optional<T> zurück"]
D -->|Nein| F{"Gibt es einen offiziellen Sentinel (npos etc.)?"}
F -->|Ja| G["Geben Sentinel zurück und prüfen in der Nähe der Nutzung"]
F -->|Nein| H["Wahrscheinlich optional oder API-Überdenken notwendig"]
(Eine Diagramm ersetzt nicht das Nachdenken, spart aber ein paar Nerven — und Nerven kann man bekanntlich nicht in Git committen.)
6. Praktisches Mini‑Refactoring „LibraryLite“: API ehrlich machen
Jetzt bündeln wir die drei Ideen in einem kleinen „Stück Anwendung“, um den Unterschied nicht nur abstrakt, sondern in einem realen Szenario zu sehen.
Stellen wir uns vor, dass wir in main() den Benutzerbefehl «find <id>» verarbeiten und folgendes wollen: id parsen, Buch finden, Ergebnis ausgeben.
Parsing: optional<int>
#include <iostream>
#include <optional>
#include <string>
std::optional<int> ParseInt(std::string_view s);
int main() {
const std::string idText = "2";
const auto id = ParseInt(idText);
if (!id) {
std::cout << "Bad id\n";
return 0;
}
std::cout << "Parsed id=" << *id << "\n"; // Parsed id=2
}
Wir geben nicht -1 zurück, weil -1 ein gültiger id‑Wert sein kann (wenn auch seltsam). Wir verwenden optional, weil es sich um ein Berechnungsergebnis handelt.
Objektsuche: const Book*
#include <iostream>
#include <vector>
const Book* FindBookById(const std::vector<Book>& books, int id);
int main() {
const std::vector<Book> books{{1, "Dune"}, {2, "1984"}};
if (const Book* b = FindBookById(books, 2)) {
std::cout << b->title << "\n"; // 1984
} else {
std::cout << "Not found\n";
}
}
Wir geben einen Pointer zurück, weil das „Ergebnis“ kein neuer Wert ist, sondern Zugriff auf ein existierendes Buch.
Suche „innerhalb eines Strings“: Sentinel (npos)
Angenommen, wir wollen Bücher hervorheben, deren Titel das Wort „Ring“ enthält:
#include <iostream>
#include <string>
bool Contains(const std::string& text, const std::string& what);
int main() {
const std::string title = "The Lord of the Rings";
if (Contains(title, "Ring")) {
std::cout << "Matched\n"; // Matched
}
}
Inside Contains ist npos versteckt, und der Aufrufer muss sich nicht an die Sentinel‑Konvention von find erinnern. Das ist ein kleines API‑Design, das den Code in main() vereinfacht.
7. Typische Fehler
Fehler Nr. 1: das „magische“ Zahl (-1) zurückgeben statt std::optional.
Anfangs scheint das praktisch: „mein Index, also bedeutet -1 nicht gefunden“. Später stellt sich heraus, dass Ihr Index ein size_t ist und -1 zu einer riesigen Zahl wird. Oder dass -1 in einem anderen Kontext ein gültiger Wert ist. std::optional macht das Fehlen explizit und entfernt versteckte Konventionen aus dem Code.
Fehler Nr. 2: T* als universellen „nullable‑Rückgabewert“ auch für Werte verwenden.
Manche Anfänger fangen an, Pointers für alles zurückzugeben: „mein Ergebnis kann fehlen, also gebe ich int* zurück“. Das ist fast immer ein schlechtes Zeichen: Sie vermischen „Wert-Ergebnis“ und „Objekt im Speicher“ und fügen die Frage hinzu „wer besitzt diesen int* und wie lange lebt er?“. Für Werte ist std::optional<int> wesentlich natürlicher.
Fehler Nr. 3: Sentinel wird „irgendwann später“ geprüft (oder gar nicht).
Bei npos und ähnlichen Werten liegt die Gefahr nicht im Ansatz selbst, sondern im Vergessen der Prüfung. Heute erinnern Sie sich noch an den Vergleich mit npos, morgen refactoren Sie und benutzen die Position, als wäre sie immer gültig. Wenn Sie Sentinel verwenden, prüfen Sie unmittelbar oder kapseln Sie die Prüfung in eine kleine Funktion, die bool oder optional zurückgibt.
Fehler Nr. 4: optional<T> zurückgeben, aber ihn sofort wieder in einen Sentinel umwandeln.
Manchmal sieht man seltsame Hybrid‑Logik: die Funktion liefert optional<size_t>, und der Aufrufer macht daraus „Wert oder -1“. Damit verschieben Sie das Problem nur nach oben und verstecken das Fehlen wieder in einem „speziellen Wert“. Wenn Sie sich für optional entschieden haben — lassen Sie das Fehlen im Typ und behandeln Sie die Zweige explizit.
Fehler Nr. 5: die Semantik „Objekt vs Ergebnis“ ignorieren und den Typ aus Gewohnheit wählen.
Der heimtückischste Fehler ist, T* zu wählen, nur weil „ich mit nullptr umgehen kann“, oder optional, nur weil „es gerade modern ist“. In der Praxis ist das beste Kriterium die Semantik: Pointer bedeuten Zugriff auf ein Objekt, optional bedeutet Wert‑Ergebnis, Sentinel passt zu historischen/niedrigen Schnittstellen oder Fällen, in denen ein Sentinel offiziell festgelegt ist (wie npos).
GO TO FULL VERSION