CodeGym /Corsi /C++ SELF /operator << per std::ostream — stampa degli oggetti

operator << per std::ostream — stampa degli oggetti

C++ SELF
Livello 49, Lezione 3
Disponibile

1. Perché serve una «bella» stampa degli oggetti

Se state iniziando a programmare, può sembrare che l'output sia qualcosa «per demo graziose». Ma nella pratica cout è il vostro debugger più accessibile: quando il codice si comporta in modo strano, il primo impulso è stampare i valori e capire chi sta mentendo — voi, l'utente, o il compilatore (spoiler: nella maggior parte dei casi siete voi, ma il compilatore fa finta di niente).

Il problema emerge quando nel programma compaiono oggetti — non numeri e stringhe, ma ad esempio Task, User, Point, Order. Si vuole scrivere così:

std::cout << task << '\n';

e non così:

std::cout << task.id() << " " << task.title() << " " << task.isDone() << '\n';

In primo luogo, la seconda opzione trasforma rapidamente main() in un rapporto contabile. In secondo luogo, il formato di stampa inizia a duplicarsi in dieci punti, e un bel giorno quei dieci posti stamperanno lo stesso oggetto in dieci modi diversi. Questo non è più debugging — è la serie «Rompiti il cervello: versione del regista».

2. Come è fatto operator<< per i flussi

Flussi e std::ostream

Dall'esterno sembra che la «stampa» sia semplicemente std::cout. In realtà std::cout è solo un oggetto-flusso concreto che sa ricevere dati e inviarli allo schermo. Il suo tipo è std::ostream (più precisamente, std::basic_ostream<char>, ma per ora non ci serve).

Idea importante: quando sovraccarichiamo operator<<, non vogliamo «stampare necessariamente su std::cout». Vogliamo stampare su qualsiasi flusso dello stesso tipo.

Quindi la firma corretta di operator<< prende una riferimento std::ostream&, invece di usare std::cout internamente. Così il vostro oggetto può essere stampato dove richiesto: sullo schermo, in una stringa, nel log… (anche se per ora usate solo std::cout, è meglio progettare bene fin dall'inizio — è come lavare la tazza subito, invece di aspettare che ci nasca una civiltà dentro).

Il sovraccarico di operator<< è una funzione

Il sovraccarico di un operatore è semplicemente una funzione con un nome speciale. Nessun portale segreto nel compilatore. Quando scrivete:

std::cout << x;

il compilatore cerca il sovraccarico adatto di operator<<. Se x è il vostro tipo, allora (se esiste il sovraccarico appropriato) chiamerà la vostra funzione.

E qui sorge la domanda pratica principale: dove dovrebbe vivere questa funzione — all'interno della classe o all'esterno? Per operator<< si sceglie quasi sempre una funzione libera, perché l'operando sinistro nell'espressione std::cout << x è il flusso, e il flusso non è la vostra classe, quindi non potete aggiungere un metodo a std::ostream.

La forma «metodo della classe» apparirebbe strana e si leggerebbe al contrario:

x << std::cout; // tecnicamente possibile, ma fa male alle persone

Serve che sia naturale, come per i tipi built-in. Quindi — funzione libera.

La firma canonica di operator<<

Ora viene un punto in cui è importante non solo memorizzare la firma, ma capirne la logica. La variante canonica è:

std::ostream& operator<<(std::ostream& os, const T& value);

Analizziamola come ingegneri, non come maghi che ripetono un incantesimo.

Parte Perché serve
std::ostream&
Per permettere le catene os << a << b << c — l'operatore deve restituire lo stesso flusso
std::ostream& os
Stampiamo nel flusso che ci è stato passato, non «dove ci pare a noi»
const T& value
La stampa non deve modificare l'oggetto e non deve copiarlo

Se invertite anche solo una di queste parti, o il codice non si compilerà, oppure si compilerà ma diventerà scomodo (e un output scomodo è come una tastiera comoda senza il tasto Backspace: si può vivere, ma perché farlo).

Perché restituiamo std::ostream&

L'idea della catena di output è la ragione per cui lo streaming è così comodo. Scriviamo:

std::cout << "Task: " << task << '\n';

e funziona perché ogni operator<< restituisce un riferimento al flusso, così il successivo << continua a scrivere nello stesso luogo. Mentalmente è come una catena di montaggio: si forniscono al flusso pezzi che vengono combinati in un'unica uscita.

Si può immaginare con questo piccolo schema:

flowchart LR
    A["std::cout"] -->|"stampa la stringa Task:"| B["lo stesso flusso"]
    B -->|"stampa la variabile task"| C["lo stesso flusso"]
    C -->|"stampa il carattere di newline"| D["lo stesso flusso"]

Se operator<< restituisse void, la catena si romperebbe e dovreste scrivere l'output su più righe. A volte non è un problema, ma spesso è solo un inutile fastidio.

Perché stampiamo const T&, e non per valore

Quando passate un oggetto «per valore», viene fatta una copia. Per int questo è trascurabile, ma per un tipo che contiene std::string diventa significativo. Inoltre, lo scopo della stampa è di solito osservare l'oggetto corrente, non la sua «fotocopia» (che può essere anche costosa).

In più, la stampa è quasi sempre un'operazione di «osservazione», non di «azione». Quindi la stampa non deve modificare l'oggetto. Perciò const T& è insieme ottimizzazione e contratto: «guardo, ma non tocco».

3. Esempio pratico: Task e tracker di task

Per non stampare «Point/Person» astratti nel vuoto, continuiamo con l'app di esempio: un semplice tracker di task su console. Potremmo avere una classe Task con l'invariante «id non negativo» e getter ordinati.

Creiamo una versione minimale della classe:

#include <string>
#include <utility>

class Task {
public:
    Task(int id, std::string title)
        : id_(id), title_(std::move(title)) {}

    int id() const { return id_; }
    const std::string& title() const { return title_; }
    bool is_done() const { return done_; }

private:
    int id_{0};
    std::string title_;
    bool done_{false};
};

Nota: il getter title() restituisce const std::string&. Questo è esattamente ciò di cui parlavamo: non copiare la stringa inutilmente e non permettere di modificare title_ dall'esterno.

Scriviamo operator<< tramite l'interfaccia pubblica

Aggiungiamo la stampa. La soluzione più semplice e «onesta» è tramite l'interfaccia pubblica:

#include <ostream>

std::ostream& operator<<(std::ostream& os, const Task& t) {
    os << "Task{id=" << t.id()
       << ", title=\"" << t.title()
       << "\", done=" << t.is_done() << "}";
    return os;
}

Sì, abbiamo usato \" — perché vogliamo stampare il title tra virgolette. È un dettaglio che rende l'output più leggibile: si vede dove inizia e finisce la stringa, specialmente se contiene spazi.

Verifichiamo in main()

Ora vogliamo vedere che tutto funzioni come promesso. Il test è brevissimo:

#include <iostream>

int main() {
    Task t{1, "Buy milk"};
    std::cout << "Created: " << t << '\n';
}

Se tutto è corretto, l'output sarà più o meno così:

// Created: Task{id=1, title="Buy milk", done=0}

Qui done=0, perché il tipo bool per default viene stampato come 0/1. È corretto, ma si può rendere più user-friendly.

Formato di stampa come contratto

Quando aggiungete operator<< a un tipo, in pratica promettete ai colleghi (e al vostro io futuro) che l'oggetto verrà stampato in modo prevedibile. Perciò è importante scegliere un formato e mantenerlo.

Un punto sottile ma importante: non stampare il newline all'interno di operator<<. A volte viene la voglia di scrivere:

os << "Task{...}\n";

Ma questo limita la flessibilità. Il chiamante potrebbe voler stampare l'oggetto senza newline, o a metà riga, o più oggetti sulla stessa riga. Il newline è responsabilità di chi costruisce la stringa finale.

Se volete un bool «umano», usate std::boolalpha dove stampate:

#include <iostream>

int main() {
    Task t{1, "Buy milk"};
    std::cout << std::boolalpha << t << '\n';
}

Ora l'output sarà:

// Task{id=1, title="Buy milk", done=false}

Stampare un contenitore di task

La magia più piacevole di operator<< si vede non su un singolo oggetto, ma quando gli oggetti sono molti. Per esempio, abbiamo una lista di task in std::vector<Task> e vogliamo stamparli tutti.

Con operator<< il ciclo diventa molto più pulito:

#include <iostream>
#include <vector>

int main() {
    std::vector<Task> tasks = { {1, "Buy milk"}, {2, "Learn C++"} };

    for (const Task& t : tasks) {
        std::cout << t << '\n';
    }
}

Output:

// Task{id=1, title="Buy milk", done=0}
// Task{id=2, title="Learn C++", done=0}

Senza operator<< quel ciclo si sarebbe trasformato in una «costruzione di stringhe dai singoli campi» in ogni punto dove volete stampare i task. Ora l'output è centralizzato: cambiando il formato in un posto, cambia ovunque.

Se non ci sono getter: friend operator<<

A volte non volete fornire getter per ogni campo. Per esempio perché è un campo interno e non volete che qualcuno ne faccia affidamento. Ma può essere necessario stampare (per log o diagnostica).

In questo caso C++ permette di dichiarare l'operatore amico della classe. Questo significa: «a questa funzione è permesso guardare nei membri private». La parola friend suona carina, ma in pratica è un'apertura controllata dell'incapsulamento — usatela con giudizio.

Esempio:

#include <ostream>
#include <string>
#include <utility>

class Task {
public:
    Task(int id, std::string title)
        : id_(id), title_(std::move(title)) {}

    friend std::ostream& operator<<(std::ostream& os, const Task& t) {
        return os << "Task{id=" << t.id_ << ", title=\"" << t.title_ << "\"}";
    }

private:
    int id_{0};
    std::string title_;
};

Nota: l'operatore è definito direttamente dentro la classe. È permesso. Ma nella pratica didattica spesso è più comodo tenere l'operatore vicino alla classe (più in basso nel file), per evitare che la classe diventi un «dump di tutto». Qui abbiamo mostrato l'uso di friend e che può essere compatto.

Stampare in una stringa tramite std::ostringstream

A volte non si vuole stampare subito in console, ma ottenere una stringa. Per esempio, per confrontare come l'oggetto viene serializzato (o semplicemente per comporre un messaggio). Per questo è comodo usare un flusso su stringa.

Se avete già visto std::stringstream, allora std::ostringstream è la sua «versione solo output».

#include <sstream>
#include <string>

std::string to_string(const Task& t) {
    std::ostringstream out;
    out << t;
    return out.str();
}

Ora potete fare così:

#include <iostream>

int main() {
    Task t{3, "Write report"};
    std::cout << to_string(t) << '\n';
}

Questo è uno dei modi più tranquilli per verificare che operator<< produca esattamente il formato che vi aspettate.

4. Errori tipici quando si scrive operator<<

Errore №1: stampare su std::cout dentro l'operatore.
Sembra logico: «voglio lo schermo». Ma in questo modo legate la funzione a un flusso concreto e rompete l'universalità. L'operatore corretto stampa su os che gli viene passato; altrimenti l'espressione con << smette di essere «trasparente».

Errore №2: restituire void o restituire il flusso per valore.
Se restituite void, le catene di output smetteranno di funzionare, e l'espressione std::cout << "x=" << obj << '\n' si romperà. Se restituite il flusso per valore, tenterete di copiare il flusso (di solito impossibile e sicuramente non quello che volete). La canonica è restituire std::ostream&.

Errore №3: prendere l'oggetto per valore invece che come const T&.
Passando per valore fate una copia inutile dell'oggetto. Per tipi semplici è trascurabile, ma per tipi con stringhe/vettori è lavoro extra. In più perdete il senso «la stampa non modifica l'oggetto»: per valore stampate una copia e il contratto diventa confuso.

Errore №4: inserire '\n' (o std::endl) dentro operator<<.
Un operatore del genere decide improvvisamente «dove finisce la riga». Di conseguenza non potrete inserire l'oggetto in messaggi più complessi, tabelle o nella stessa riga con altri oggetti. Il newline deve essere all'esterno — del codice chiamante.

Errore №5: rendere il formato «troppo intelligente» e instabile.
Se operator<< comincia a stampare diversamente in base allo stato esterno, flag casuali o logiche nascoste («a volte stampiamo la modalità dettagliata, a volte quella breve»), l'output smette di essere uno strumento affidabile di diagnostica. Meglio un formato chiaro e pochi sorprese — specialmente nelle prime fasi di apprendimento.

1
Compito
C++ SELF, livello 49, lezione 3
Bloccato
Stampa di un punto
Stampa di un punto
1
Compito
C++ SELF, livello 49, lezione 3
Bloccato
Tabellone orario
Tabellone orario
1
Compito
C++ SELF, livello 49, lezione 3
Bloccato
Libro come stringa
Libro come stringa
1
Compito
C++ SELF, livello 49, lezione 3
Bloccato
Uniformare gli ordini
Uniformare gli ordini
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION