1. Perché servire il mapping struct ↔ JSON
Quando inizi a scrivere un programma, sembra logico lavorare «direttamente»: ecco l'oggetto JSON, ecco la chiave "name", ecco j["name"]. E i primi tempi può essere anche piacevole: veloce, senza strati aggiuntivi, compila, e ti senti il padrone dei dati.
Il problema inizia alla seconda settimana di vita del progetto (cioè circa 15 minuti dopo il primo avvio riuscito). Le chiavi si disperdono nel codice, compaiono «stringhe magiche»: qualcuno scrive "userName", qualcun altro "username", un altro ancora "user_name" — e tutto questo è lo stesso campo, ma in universi diversi. Il mapping è un modo per raccogliere le regole di trasformazione in un unico posto e smettere di giocare a «trova l’errore di battitura».
Obiettivo della lezione: imparare a scegliere tra i due approcci e capire i criteri di scelta, non agire per il principio «perché si fa così».
Due approcci: manuale e tramite to_json/from_json
È importante togliersi subito la paura: entrambi gli approcci sono validi. La serializzazione manuale non è una «vergogna da principianti», ma uno strumento che a volte è davvero migliore. E to_json/from_json non è «magia», è semplicemente disciplina: stabilite dove vivono le chiavi e le regole.
La differenza è più o meno come tra «cucinare seguendo una ricetta» e «cucinare ad occhio». Ad occhio a volte è più veloce e gustoso — ma se vuoi ripetere il piatto fra un mese, scoprirai che «un pizzico di sale» per ognuno significa cose diverse, e «friggere fino a cottura» il compilatore non lo controlla.
Prima vedremo l'approccio manuale, poi to_json/from_json, e infine tireremo fuori criteri comprensibili per la scelta.
2. Serializzazione manuale
La serializzazione manuale è quando crei esplicitamente un nlohmann::json e leggi da esso esplicitamente. Di solito si presenta come due funzioni: serializeXxx(...) e parseXxx(...). Il vantaggio di questo approccio è che è diretto e facile da debugare: vedi letteralmente dove metti cosa.
Lo svantaggio emerge dopo: la logica di lettura e scrittura inizia a duplicarsi, le chiavi si disperdono, e le modifiche al formato richiedono di editare molti posti. Ma per strutture piccole e formati usa e getta l'approccio manuale spesso vince per semplicità.
Mini-esempio di serializzazione manuale per Task
Immaginiamo di avere un'app didattica «TaskBox» — un gestore di attività da console che abbiamo sviluppato da quando abbiamo imparato a usare struct, vector, file e controlli di input. Per ora senza versioni del formato e migrazioni (questo verrà più avanti), salviamo semplicemente la lista di task in JSON.
#include <nlohmann/json.hpp>
#include <string>
struct Task {
int id{};
std::string title;
bool done{};
};
nlohmann::json serialize_task(const Task& t) {
return nlohmann::json{{"id", t.id}, {"title", t.title}, {"done", t.done}};
}
Codice breve e chiaro. Qui è facile mettere un breakpoint e osservare l'albero JSON a occhio.
Deserializzazione manuale
Ora leggiamo indietro. E qui compare la prima biforcazione: fai in modo rigoroso (campi obbligatori tramite at()), oppure in modo più morbido (tramite value() con valori di default). Nel programma del giorno la validazione avrà una lezione separata, quindi ora mostriamo «rigoroso e semplice», senza default sofisticati.
#include <nlohmann/json.hpp>
#include <string>
Task parse_task_strict(const nlohmann::json& j) {
Task t;
t.id = j.at("id").get<int>();
t.title = j.at("title").get<std::string>();
t.done = j.at("done").get<bool>();
return t;
}
Funziona, ma c'è una sfumatura: se la struttura non è corretta o il tipo del campo è diverso — verrà lanciata un'eccezione. Nella fase «far funzionare» va bene. Nella fase «non far cadere tutto sui dati sporchi» serve la validazione (lezione successiva).
Dove il metodo manuale comincia a far male
Il problema non è tanto il JSON in sé, quanto il fattore umano.
Hai aggiunto il campo priority in Task. Poi hai dimenticato di aggiornare serialize_task. Oppure hai aggiornato la serializzazione ma dimenticato il parsing. O hai aggiornato tutto, ma in un punto hai scritto la chiave "prio", perché «era più veloce». E tutto: i dati hanno cominciato a salvare «in modo strano».
Il metodo manuale diventa fragile quando:
- la struttura è usata in molti punti,
- il formato vive a lungo,
- ci sono molte entità,
- ci sono modelli annidati (Project contiene vector<Task>),
- ci sono campi opzionali e serve una regola unica.
4. to_json/from_json: regole vicine al tipo
L'approccio to_json/from_json di nlohmann::json è un modo per dire: «Per il tipo Task ecco le regole su come trasformarlo in JSON e viceversa». Così il codice esterno diventa più pulito: puoi scrivere json j = task; e Task t = j.get<Task>();.
Non è «serializzazione automatica dal nulla». Scrivi ancora le regole a mano, le metti però in un posto standard e ottieni un'interfaccia unica.
Idea chiave: non spargere le chiavi nel progetto, ma tenerle vicine al modello (o nel modulo di serializzazione del modello).
Esempio di to_json/from_json per Task
#include <nlohmann/json.hpp>
#include <string>
struct Task {
int id{};
std::string title;
bool done{};
};
void to_json(nlohmann::json& j, const Task& t) {
j = nlohmann::json{{"id", t.id}, {"title", t.title}, {"done", t.done}};
}
void from_json(const nlohmann::json& j, Task& t) {
t.id = j.at("id").get<int>();
t.title = j.at("title").get<std::string>();
t.done = j.at("done").get<bool>();
}
Nota: la logica è quasi la stessa del metodo manuale. La differenza è che ora la libreria sa chiamare automaticamente queste funzioni.
Come appare «dall'esterno»
#include <nlohmann/json.hpp>
#include <iostream>
int main() {
Task t{1, "Write JSON lecture", false};
nlohmann::json j = t;
std::cout << j.dump() << '\n'; // {"done":false,"id":1,"title":"Write JSON lecture"}
Task back = j.get<Task>();
std::cout << back.id << ' ' << back.title << '\n'; // 1 Write JSON lecture
}
Questo è il vantaggio principale: il codice esterno non deve più conoscere le chiavi "id", "title", "done". Lavora in termini di Task.
5. Criteri di scelta
In un progetto reale non scegli «una volta per tutte». A volte usi to_json/from_json per i modelli principali e serializzazione manuale per alcuni pacchetti tecnici o formati temporanei.
Per non indovinare, è utile tenere i criteri in forma di tabella. Non è una legge, ma una scaletta per la testa.
| Criterio | Serializzazione manuale | to_json/from_json |
|---|---|---|
| Dimensione del progetto | Adatta per piccole aree «usa e getta» | Adatta per progetti medi e grandi |
| Numero di modelli | 1–2 modelli sono ancora tollerabili | Più modelli ci sono, più conviene |
| Rischio di «stringhe magiche» | Alto, le chiavi si disperdono | Basso, le chiavi sono localizzate |
| Codice esterno | Spesso deve conoscere le chiavi | Lavora con i tipi (Task, Project) |
| Testing | Bisogna testare le funzioni manualmente | Anche qui testi, ma l'interfaccia è unica (get<T>) |
| Flessibilità | Molto flessibile | Anche flessibile, ma a volte si preferiscono funzioni separate |
| Gestione errori | Tutto a carico tuo | Anche qui a carico tuo, ma i punti d'ingresso sono standardizzati |
Ora traduciamo la tabella in un linguaggio umano.
L'approccio manuale spesso vince quando scrivi uno script/utility piccolo, hai un JSON in ingresso e uno in uscita, e sei sicuro che il formato non vivrà per anni. to_json/from_json è preferibile quando il formato diventa parte dell'applicazione, quando i dati vengono salvati su file e devono essere letti domani, dopodomani e dopo la tua sessione estiva.
6. Dettagli utili sul mapping
Localizzazione delle chiavi: una verità per tutto il progetto
Anche usando to_json/from_json, resta il problema che le chiavi sono comunque stringhe. Buona pratica è far convivere le chiavi in un unico posto e usarle sempre allo stesso modo.
Questo è particolarmente importante quando avete più sviluppatori o quando voi stessi, dopo una settimana, dimenticate come avevate chiamato un campo (e lo dimenticherete, non dubitate — non è un bug, è una «caratteristica» della memoria a breve termine).
Trucco: namespace keys
#include <string_view>
namespace task_keys {
inline constexpr std::string_view id = "id";
inline constexpr std::string_view title = "title";
inline constexpr std::string_view done = "done";
}
Ora in from_json e to_json usi task_keys::id invece di "id".
Uso delle chiavi in from_json
#include <nlohmann/json.hpp>
void from_json(const nlohmann::json& j, Task& t) {
t.id = j.at(task_keys::id).get<int>();
t.title = j.at(task_keys::title).get<std::string>();
t.done = j.at(task_keys::done).get<bool>();
}
Sì, è qualche carattere in più. Ma quando rinomini la chiave la cambi in un solo punto anziché in dieci.
optional e contratto: «campo assente» vs «campo = null»
Con std::optional prima o poi ti confronti, perché i dati reali non sono sempre completi. Qui conta non solo «come scrivere il codice», ma quale contratto scegli.
Nel mapping hai due politiche ragionevoli.
Prima politica: «se l'optional è vuoto — il campo nel JSON non c'è affatto». Di solito è la scelta più comoda e compatta.
Seconda politica: «il campo c'è sempre, ma a volte ha valore null». Serve quando vuoi distinguere «campo assente» da «campo azzerato esplicitamente».
Per ora (prima della lezione su validazione e default) ci fermiamo alla prima politica, perché è più semplice per cominciare e funziona nella maggior parte dei formati applicativi.
Esempio: optional<std::string> note
#include <nlohmann/json.hpp>
#include <optional>
#include <string>
struct Task {
int id{};
std::string title;
bool done{};
std::optional<std::string> note;
};
void to_json(nlohmann::json& j, const Task& t) {
j = nlohmann::json{{"id", t.id}, {"title", t.title}, {"done", t.done}};
if (t.note) {
j["note"] = *t.note;
}
}
Lettura indietro
#include <nlohmann/json.hpp>
#include <optional>
#include <string>
void from_json(const nlohmann::json& j, Task& t) {
t.id = j.at("id").get<int>();
t.title = j.at("title").get<std::string>();
t.done = j.at("done").get<bool>();
if (j.contains("note")) {
t.note = j.at("note").get<std::string>();
} else {
t.note.reset();
}
}
Punto semantico: contains("note") controlla l'assenza del campo. is_null() è uno stato diverso, che tratteremo nella lezione su «validazione e default».
Strutture annidate e container: l'effetto domino
Quando configuri il mapping per un tipo, la libreria standard e nlohmann::json permettono di «scalare» questo ai container. È un effetto molto comodo: descrivi le regole per Task, e improvvisamente vector<Task> comincia a serializzarsi quasi da solo.
È come domino, ma senza il drammatico crollo in produzione (anche se non lo escluderei del tutto).
Modello «project» con task
#include <string>
#include <vector>
struct Project {
std::string name;
std::vector<Task> tasks;
};
to_json/from_json per Project
#include <nlohmann/json.hpp>
void to_json(nlohmann::json& j, const Project& p) {
j = nlohmann::json{{"name", p.name}, {"tasks", p.tasks}};
}
void from_json(const nlohmann::json& j, Project& p) {
p.name = j.at("name").get<std::string>();
p.tasks = j.at("tasks").get<std::vector<Task>>();
}
Nota la riga {"tasks", p.tasks} e get<std::vector<Task>>(). Funziona perché per Task esistono già to_json/from_json.
7. Scenario pratico: salvataggio e caricamento di TaskBox
Mettiamo insieme i pezzi. Non creiamo un «perfetto prodotto», ma una versione didattica e leggibile: salvare un progetto in un file JSON e ricaricarlo. File e stream li abbiamo già visti, il parsing JSON pure, e oggi facciamo in modo che il codice non diventi una zuppa di chiavi.
Di seguito ci saranno alcune funzioni brevi — proprio come si scrive nella realtà: piccoli pezzi per non fondere il cervello.
Salvare Project su file
#include <nlohmann/json.hpp>
#include <fstream>
#include <string>
bool save_project(const Project& p, const std::string& path) {
std::ofstream out(path);
if (!out) return false;
nlohmann::json j = p;
out << j.dump(2);
return true;
}
Qui la bellezza sta in una riga: nlohmann::json j = p;. Tutta la «sporcizia» delle chiavi è rimasta dentro to_json.
Caricare Project da file
#include <nlohmann/json.hpp>
#include <fstream>
#include <string>
bool load_project(Project& p, const std::string& path) {
std::ifstream in(path);
if (!in) return false;
nlohmann::json j;
in >> j;
p = j.get<Project>();
return true;
}
Questa versione è «ottimistica»: assume che il formato sia corretto. Se il formato non è valido, get<Project>() può lanciare un'eccezione — e questo lo sistemeremo nella lezione successiva (validazione + default).
Come sarebbe senza mapping
Per farvi sentire il criterio di scelta in pratica, immaginate che load_project invece di j.get<Project>() faccia così:
- j.at("name")...
- j.at("tasks")...
- un ciclo sull'array
- all'interno del ciclo task.at("id")... e così via
E tutto questo direttamente nella funzione di caricamento del file. Funziona, ma le funzioni diventano lunghe, difficili da leggere, e la cosa più spiacevole è che il «formato» risulta sparso nel codice. Quindi il formato non è più un «contratto dell'applicazione», ma un «effetto collaterale di come hai scritto il ciclo oggi».
Dove mettere to_json/from_json nel progetto
Vorrei dare una ricetta universale «come mettere le cose nelle cartelle», ma il corso procede per passi, e l'architettura più ampia verrà dopo. Ora è importante solo una disciplina di base: le regole siano in un solo posto.
Per un progetto didattico si può tenere la struct Task e i suoi to_json/from_json vicini nello stesso .cpp (o anche nello stesso main.cpp, finché il progetto è piccolo). Più avanti, con la separazione in .hpp/.cpp, la logica sarà: il modello è dichiarato nell'header, e la serializzazione o è accanto (se è parte del contratto pubblico) o in un modulo di serializzazione separato (se vuoi separare il modello di dominio dal formato di persistenza).
L'importante è non cadere in uno «stato a metà», dove to_json sta in un posto, from_json in un altro, e nessuno è sicuro che siano simmetrici. È come costruire un ingresso in un labirinto e dimenticare l'uscita.
8. Errori tipici
Errore №1: mescolare la serializzazione con la business logic.
A volte viene la tentazione: «già che leggo il JSON, controllo qui che id > 0, e se no lo correggo». Alla fine la serializzazione comincia a prendere decisioni per il dominio: sostituire valori, «curare» i dati, inserire default silenziosamente. Questo è comodo fino al momento in cui cerchi di capire da dove sia arrivato nel programma un task con id = 0. La serializzazione dovrebbe trasformare il formato, non cambiare il significato dei dati.
Errore №2: dispersione delle chiavi nel codice («stringhe magiche»).
Anche con to_json/from_json uno sviluppatore può iniziare a leggere il JSON direttamente in altri punti: «mi serve solo il campo "name" qui». Poi questi punti diventano dieci, poi rinomini la chiave, aggiorni to_json, ma metà del codice continua a leggere la chiave vecchia. Se scegli il mapping, cerca di fare in modo che il codice esterno lavori col struct, non con le chiavi.
Errore №3: asimmetria «scriviamo una cosa — leggiamo un'altra».
Classico: in to_json hai scritto "done", ma in from_json leggi "is_done". Il compilatore non dice nulla: entrambe le stringhe sono valide. L'errore salta fuori solo a runtime. Per questo è utile centralizzare le chiavi (almeno via namespace keys) e, se possibile, scrivere serializzazione e deserializzazione vicine per vedere la simmetria a colpo d'occhio.
Errore №4: considerare from_json come validazione.
from_json è «spostare valori dal JSON ai campi». Può lanciare se i tipi non corrispondono, ma non è obbligato a controllare che una stringa non sia vuota, che id sia dentro un range, o che un array non sia troppo grande. La validazione è un argomento separato e un livello di responsabilità diverso. Se cerchi di infilare tutta la validazione dentro from_json, questa crescerà e diventerà difficile da leggere.
Errore №5: confondere «campo assente» e «campo = null» con optional.
Se oggi tratti optional come «campo assente», e domani inizi a scrivere talvolta null, tra una settimana avrai tre stati invece di due, e nessuno ricorderà cosa significano. Scegli un contratto (per esempio «optional → campo assente») e rispettalo sempre. E se serve null, va bene anche quello, ma decidetelo esplicitamente e applicatelo ovunque.
GO TO FULL VERSION