1. Warum JSON überhaupt validieren
Wenn JSON erfolgreich geparst wurde, denkt der Anfänger gern: „Na also, die Daten sind korrekt!“ Das ist ungefähr so, wie sich darüber zu freuen, einen Umschlag in der Hand zu haben — das bedeutet nicht, dass sich darin genau die erwarteten Dokumente befinden oder dass sie nicht mit Bleistift auf einer Serviette geschrieben sind. Parsing garantiert nur eines: der Text war syntaktisch JSON. Es garantiert nicht, dass die notwendigen Felder vorhanden sind, dass sie den richtigen Typ haben, dass Zahlen im erlaubten Bereich liegen, oder dass die Zeichenkette "42" nicht plötzlich anstelle der Zahl 42 angekommen ist.
Darum unterscheiden wir zwei große Problemgruppen. Die erste — „das ist überhaupt kein JSON“ (Parsing‑Fehler). Die zweite — „das ist JSON, aber nicht unser Format“ (Struktur/Typen/Vertragsfehler). Heute beschäftigen wir uns mit der zweiten Gruppe: wir lernen zu prüfen, ob JSON den Erwartungen unseres Programms entspricht, und setzen dort Defaults gezielt ein, wo sie wirklich Teil des Vertrags sind.
Als Orientierung ist es nützlich, eine einfache Pipeline im Kopf zu behalten:
flowchart LR
A[Text/Datei] --> B[parse: erhaltenen json‑Baum]
B --> C[Validierung von Struktur und Typen]
C --> D["Lesen in struct (from_json / manuell)"]
D --> E[Programmlogik]
2. Feldvertrag: erforderlich, optional und mit Default
Der Übergang von „JSON gelesen und ich vertraue ihm“ zu „ich habe einen Formatvertrag“ ist ein Reifegrad der Anwendung. Ein Vertrag muss nicht zwangsläufig ein 30‑seitiges Dokument sein (obwohl das gelegentlich vorkommt), sondern zumindest eine ehrliche Antwort auf Fragen: welche Felder sind erforderlich, welche können fehlen, und welche können fehlen, und dann füllen wir einen Standardwert ein. Wenn Sie diese Antwort nicht geben, beantwortet das Programm die Frage selbst — meist durch einen Absturz im denkbar ungünstigen Moment.
Wir einigen uns am Beispiel einer Mini‑App, die eine Aufgabenliste (Task List) im JSON speichert. Das Modell ist einfach:
— erforderliche ganze Zahl.id
— erforderlicher String.title
— optionales Feld; fehlt es, nehmen wirdone
an.false
— optionales Feld mit Defaultpriority
.0
— optionale Zeichenkette ohne Default (kann fehlen).note
Tabellarisch sieht das so aus:
| Feld | Kategorie | Typ in JSON | Typ in C++ | Was tun, wenn Feld fehlt |
|---|---|---|---|---|
|
erforderlich | number (integer) | |
Fehler |
|
erforderlich | string | |
Fehler |
|
optional mit Default | boolean | |
|
|
optional mit Default | number (integer) | |
|
|
optional ohne Default | string (oder null, je nach Entscheidung) | |
|
Beachte den feinen Unterschied: „optional“ und „mit Default“ sind unterschiedliche Konzepte. Optional ohne Default bedeutet, dass das Fehlen des Feldes auch ein normales Zustandsbild ist — einfach kein Wert vorhanden. Optional mit Default bedeutet, dass das Fehlen des Feldes als ein konkreter Wert interpretiert wird, den wir im Vertrag gewählt haben.
3. Werkzeuge von nlohmann::json zur Prüfung
Wenn Sie früher Code wie j.at("id").get<int>() geschrieben haben, dann kennen Sie bereits den „strengen Stil“: Feld fehlt — Exception, falscher Typ — Exception. Das ist in Ordnung, aber sobald mindestens ein optionales Feld auftaucht, möchte man nicht unbedingt abstürzen, sondern sich vorhersehbar verhalten. An dieser Stelle ist es wichtig, nicht operator[] als universellen Dietrich zu missbrauchen — er ist praktisch, aber tückisch: bei nicht‑const JSON kann er ein Feld erstellen, das vorher nicht existierte, und später sucht man stundenlang, wer die Konfiguration mit seltsamem Müll ergänzt hat.
Lassen Sie uns die Rollen der wichtigsten Werkzeuge festhalten:
contains("key") beantwortet die Frage „gibt es das Feld überhaupt?‘“.
at("key") gibt Zugriff auf das Feld, ist aber streng: fehlt das Feld — Exception. Das ist nützlich für erforderliche Felder nach der Validierung oder wenn Sie wirklich möchten, dass es „abstürzt, falls nicht vorhanden“.
is_string(), is_boolean(), is_number_integer(), is_array(), is_object(), is_null() ermöglichen die Typprüfung vor dem Aufruf von get<T>(). Das ist wichtig, weil get<T>() bei Typinkongruenz meist eine Exception wirft — und Ihr Programm zur Glückssache wird, „wo es abstürzt“.
value("key", default) erlaubt das bequeme Lesen optionaler Felder mit Default … aber ein Haken: Der Default greift, wenn das Feld nicht vorhanden ist. Wenn das Feld existiert, aber den falschen Typ hat, wirft die Bibliothek meistens trotzdem eine Exception. Das heißt: value() ersetzt keine Validierung, sondern macht den Code nur kürzer, wenn Sie sich auf den Vertrag geeinigt haben.
Ein kleines Beispiel, das zeigt, dass value() kein „Staubsauger ist, der jeden Müll verschluckt“:
#include <nlohmann/json.hpp>
#include <iostream>
int main() {
nlohmann::json j = {{"done", "yes"}}; // string statt bool
try {
bool done = j.value("done", false);
std::cout << done << '\n';
} catch (const std::exception& e) {
std::cout << "Oops: " << e.what() << '\n'; // Oops: ... type must be boolean ...
}
}
Die Botschaft ist klar: Defaults sind Teil des Vertrags, nicht ein Mittel, Probleme zu verschleiern.
4. Feld fehlt vs. null: Bedeutung festlegen
Beim Validieren von JSON stößt man bald auf die philosophische Frage: Worin unterscheidet sich „Feld fehlt“ von „Feld existiert und ist null“? Und warum diskutieren Programmierer das mit solcher Leidenschaft, als ginge es um den besten Editor (Tipp: der beste ist der, mit dem Sie rechtzeitig abgeben).
Aus JSON‑Sicht sind das unterschiedliche Zustände, und sehr oft müssen sie unterschiedlich interpretiert werden. Beispielsweise kann note: null bedeuten „der Nutzer hat die Notiz bewusst gelöscht“, während das Fehlen von note bedeuten kann „in einer alten Version des Formats gab es dieses Feld noch nicht“. Oder umgekehrt: Sie entscheiden, dass null völlig verboten ist — dann ist note: null ein Fehler.
Heute ist unsere Aufgabe nicht, die „richtige“ Bedeutung zu erraten, sondern sie im Vertrag festzulegen und Code zu schreiben, der diesem Vertrag folgt. Für optionale Strings wählt man oft eine von zwei Strategien:
- nur das Fehlen des Feldes erlauben (wenn note nicht vorhanden, dann nullopt), und null als Fehler behandeln;
- sowohl Fehlen als auch null erlauben und beide Fälle als nullopt interpretieren.
Wir zeigen die zweite Variante (datenfreundlicher), weil sie beim Laden von Konfigurationen und alten Dateien oft praktisch ist:
#include <nlohmann/json.hpp>
#include <optional>
#include <string>
std::optional<std::string> read_optional_string(const nlohmann::json& j, const char* key) {
if (!j.contains(key) || j.at(key).is_null()) {
return std::nullopt;
}
if (!j.at(key).is_string()) {
return std::nullopt; // oder besser als Validierungsfehler behandeln
}
return j.at(key).get<std::string>();
}
Beachten Sie: das ist ein Beispiel fürs „weiche“ Lesen. In echter Validierung möchten wir meist nicht stillschweigend nullopt zurückgeben, sondern mitteilen: „Feld note muss String oder null sein“.
5. Muster: separat validieren, separat in struct lesen
Zu Beginn möchte man oft alles in einer Funktion erledigen: prüfen, lesen, gleich ins Vector pushen und noch ausgeben. Bei kleinen Beispielen wirkt das praktisch. Im realen Code entsteht ein schwer testbarer und gefährlicher „Kombinator“. Ein guter Lehransatz sieht daher so aus:
- die Validierungsfunktion sagt: „das sieht nach unserem Format aus oder nicht“ und liefert eine verständliche Fehlermeldung;
- die Lese‑Funktion (from_json oder ein manueller Parser) geht davon aus, dass die Daten bereits validiert sind, und kümmert sich nur um die Umwandlung in C++‑Typen.
Lassen Sie uns mit dem Modell Task beginnen:
#include <optional>
#include <string>
struct Task {
int id{};
std::string title;
bool done{false}; // Default gemäss Vertrag
int priority{0}; // Default gemäss Vertrag
std::optional<std::string> note; // optional
};
Validierung einer Aufgabe: erforderliche Felder und Typen
Wir schreiben eine Funktion, die std::optional<std::string> zurückgibt: nullopt, wenn alles okay ist, und einen Fehlerstring, wenn nicht. Das ist ein einfacher, klarer Vertrag (ohne spätere Features wie expected).
#include <nlohmann/json.hpp>
#include <optional>
#include <string>
std::optional<std::string> validate_task_json(const nlohmann::json& j) {
if (!j.is_object()) return "Task must be an object";
if (!j.contains("id") || !j.at("id").is_number_integer())
return "Task.id is required and must be integer";
if (!j.contains("title") || !j.at("title").is_string())
return "Task.title is required and must be string";
if (j.contains("done") && !j.at("done").is_boolean())
return "Task.done must be boolean if present";
return std::nullopt;
}
Hier sieht man die zentrale Idee: erforderliche Felder prüfen wir als „contains + richtiger Typ“, optionale Felder als „wenn vorhanden, muss der Typ stimmen“.
Defaults heben die Wertebeschränkungen nicht auf
Für das Feld priority haben wir einen Default, aber das bedeutet nicht, dass wir beliebige Werte akzeptieren müssen. Selbst bei Default kann es Einschränkungen geben: beispielsweise muss priority zwischen 0 und 5 liegen. Das ist keine reine Typprüfung mehr, sondern die Prüfung einer Invariante.
Erweitern wir die Prüfung:
#include <nlohmann/json.hpp>
#include <optional>
#include <string>
std::optional<std::string> validate_priority(const nlohmann::json& j) {
if (!j.contains("priority")) return std::nullopt;
if (!j.at("priority").is_number_integer())
return "Task.priority must be integer if present";
int p = j.at("priority").get<int>();
if (p < 0 || p > 5)
return "Task.priority must be in range [0..5]";
return std::nullopt;
}
Die Idee: Default deckt das Fehlen des Feldes ab, rechtfertigt aber nicht „priority = -9000“. Memes sind lustig, aber die Daten sind es nicht.
Einlesen in Task nach der Validierung
Nun schreiben wir die Funktion, die das Task-Objekt aus JSON aufbaut. Sie geht davon aus, dass die Validierung bereits erfolgt ist, daher ist der Code kürzer.
#include <nlohmann/json.hpp>
#include <optional>
#include <string>
Task parse_task(const nlohmann::json& j) {
Task t;
t.id = j.at("id").get<int>();
t.title = j.at("title").get<std::string>();
t.done = j.value("done", false); // Default gemäss Vertrag
t.priority = j.value("priority", 0); // Default gemäss Vertrag
if (j.contains("note") && !j.at("note").is_null()) {
t.note = j.at("note").get<std::string>();
}
return t;
}
value() wird hier zweckentsprechend für Defaults eingesetzt. Wir hoffen nicht, dass es uns vor falschen Typen bewahrt — dafür ist die Validierung zuständig.
6. Das ganze Dokument: Root, Aufgabenarray und Laden
Die Validierung einer einzelnen Aufgabe ist gut, aber eine Datei enthält meist nicht nur eine Aufgabe, sondern ein ganzes Dokument. Das Root‑JSON kann ein Objekt wie { "tasks": [ ... ] } sein. Dabei wird ein weiterer wichtiger Punkt sichtbar: die Diagnose muss nicht einfach „schlecht“ sagen, sondern konkret auf den Ort der Fehlersituation hinweisen. Andernfalls starrt man auf die Nachricht „Task.title is required“ und fragt sich: „Bei welcher von 200 Aufgaben?!“
Wir beginnen mit einem einfachen Dateiformat:
{
"tasks": [
{ "id": 1, "title": "Buy milk", "done": false },
{ "id": 2, "title": "Learn C++", "priority": 5 }
]
}
Validierung des Dokumentwurzel
Wir schreiben die Validierung des Roots:
#include <nlohmann/json.hpp>
#include <optional>
#include <string>
std::optional<std::string> validate_root(const nlohmann::json& root) {
if (!root.is_object()) return "Root must be an object";
if (!root.contains("tasks") || !root.at("tasks").is_array())
return "Root.tasks is required and must be array";
return std::nullopt;
}
Validierung des Arrays mit Fehlerkontext
Jetzt fügen wir den „Index des Elements“ hinzu, damit die Fehlermeldung konkret ist:
#include <nlohmann/json.hpp>
#include <optional>
#include <string>
std::optional<std::string> validate_tasks_array(const nlohmann::json& arr) {
for (std::size_t i = 0; i < arr.size(); ++i) {
auto err = validate_task_json(arr.at(i));
if (err) return "tasks[" + std::to_string(i) + "]: " + *err;
}
return std::nullopt;
}
Das ist deutlich nützlicher. Ja, die Zeichenfolge mit "tasks[17]" liest sich nicht poetisch, aber sie spart Stunden Arbeit.
Praktisches Beispiel: Laden der Aufgabenliste aus einer Datei
Jetzt bauen wir ein kleines Gerüst für die Ladefunktion. Wir lesen JSON aus einer Datei, validieren das Root, validieren das Array und bauen erst dann ein std::vector<Task>. In der idealen Welt sind Daten immer korrekt. In der echten Welt ist die ideale Welt meist im Urlaub, also schreiben wir defensiven Code.
#include <nlohmann/json.hpp>
#include <fstream>
#include <iostream>
#include <optional>
#include <string>
#include <vector>
std::optional<std::string> load_tasks(const std::string& path, std::vector<Task>& out) {
std::ifstream in(path);
if (!in) return "Cannot open file: " + path;
nlohmann::json root;
in >> root;
if (auto err = validate_root(root)) return *err;
const auto& arr = root.at("tasks");
if (auto err = validate_tasks_array(arr)) return *err;
out.clear();
for (const auto& item : arr) {
out.push_back(parse_task(item));
}
return std::nullopt;
}
Betrachtet man diese Funktion als Geschichte, liest sie sich sehr logisch: „geöffnet → geparst → Struktur geprüft → jede Aufgabe geprüft → Objekte gebaut“. So sollte Code aussehen, den man guten Gewissens warten kann.
Ein kleines main, um das Verhalten zu sehen:
#include <iostream>
#include <vector>
int main() {
std::vector<Task> tasks;
if (auto err = load_tasks("tasks.json", tasks)) {
std::cout << "Load error: " << *err << '\n';
return 1;
}
std::cout << "Loaded tasks: " << tasks.size() << '\n'; // Loaded tasks: 2
}
Wann Defaults helfen und wann sie Probleme verschleiern
Defaults sind tückisch. Sie verbessern die UX, wenn das Fehlen eines Feldes tatsächlich normal ist. Gleichzeitig können sie aber Datenfehler kaschieren, wenn man Defaultwerte nur setzt, damit nichts abstürzt. Beispiel: wenn das Feld id fehlt, ist es eine schlechte Idee, 0 einzusetzen, weil Sie dann nicht zwischen „Aufgabe mit id=0“ (falls das jemals vorkommt) und „kaputtem Input“ unterscheiden können. In solchen Fällen ist ehrliches Fehlermelden die bessere Wahl.
Ein guter Kriterienvorschlag: ein Default ist akzeptabel, wenn er den Sinn nicht bricht und die Daten nicht mehrdeutig macht. Für done=false ist das meist unproblematisch: Fehlen deutet wahrscheinlich auf „noch nicht erledigt“ hin. Für priority=0 ist das ebenfalls oft in Ordnung: kein Priority‑Feld bedeutet gewöhnlich „normal“. Aber für title="" ist ein Default fragwürdig: ein leerer Titel deutet häufig auf beschädigte Daten oder seltsame Nutzereingaben hin.
Wenn Sie es etwas formaler ausdrücken wollen: erforderliche Felder sind jene, ohne die ein Objekt nicht als korrekt gelten kann; optionale Felder erweitern das Objekt, definieren aber nicht seine Existenz; Default‑Felder sind optionale Felder, für die Sie im Vorhinein die Interpretation „kein Feld = dieser Wert“ festgelegt haben.
7. Typische Fehler bei Validierung und Default‑Umgang
In diesem Abschnitt könnte man leicht in eine „Sündenliste“ abrutschen, besser ist es, die Punkte als praktische Erfahrungswerte zu merken, die Anfängern (und manchmal auch Selbstsicheren, die denken „ich weiß eh alles“) regelmäßig passieren.
Fehler Nr. 1: den erfolgreichen parse als Zeichen gültiger Daten werten.
Erfolgreiches Parsen bedeutet nur, dass das JSON syntaktisch korrekt ist. Die Struktur kann beliebig sein: anstelle eines Objekts kann ein Array stehen, anstelle einer Zahl eine Zeichenkette, notwendige Felder können fehlen. Deshalb braucht es nach dem Parsen fast immer einen separaten Schritt zur Prüfung von Struktur und Typen.
Fehler Nr. 2: value() als „universellen Retter“ gegen falsche Typen verwenden.
value("x", default) ist praktisch, wenn das Feld fehlt. Wenn das Feld aber vorhanden und vom falschen Typ ist, wird meist eine Exception geworfen. Das ist erwartetes Verhalten: die Bibliothek rät nicht, dass die Zeichenkette "yes" gleichbedeutend mit true ist. Also: value() bedeutet Defaults, nicht „jeden Müll schlucken“.
Fehler Nr. 3: mittels operator[] lesen und dabei versehentlich das JSON verändern.
Bei nicht‑const nlohmann::json kann operator[] ein fehlendes Feld erzeugen. Wenn Sie später dump() ausführen oder das JSON zurückschreiben, haben Sie unbeabsichtigt die Eingabedaten „korrigiert“, obwohl Sie sie nur lesen wollten. Zum Lesen ist die Kombination contains() + at() vorzuziehen.
Fehler Nr. 4: Defaults überall setzen und dabei Diagnosen verlieren.
Wenn Sie Defaults sogar für notwendige Felder einbauen, verliert das Programm die Fähigkeit, zwischen „alles ok“ und „Daten beschädigt“ zu unterscheiden. Fehlermeldungen verschwinden nicht — sie wandern an eine Stelle, an der die Ursache schwerer zu finden ist. Erforderliche Felder sollten erforderlich bleiben, sonst zerfasert der Vertrag.
Fehler Nr. 5: nicht zwischen „Feld fehlt“ und „Feld = null“ unterscheiden und sich dann über Inkompatibilitäten wundern.
Fehlen und null sind unterschiedliche Zustände. Wenn Sie den Vertrag nicht klar definieren, beginnt morgen jemand (oder Sie selbst) damit, null statt des Fehlens zu schreiben, und Ihr Code fällt plötzlich auseinander. Lösung: legen Sie für jedes Feld eine Regel fest und implementieren Sie sie konsistent beim Lesen und Schreiben.
GO TO FULL VERSION