1. Introduzione
Se avete visto codice nello stile getName()/setName() per ogni campo, è facile pensare: «Ok, cosi si fa». Poi però si incontra codice C++ dove al posto di getX() si scrive semplicemente x(), e al posto di setX() c'è un metodo tipo moveTo(...), e non è più così ovvio perché accade.
L'idea chiave qui: getter e setter non sono magia né un «rito obbligatorio del programmatore maturo», ma normali metodi. Il loro valore emerge quando aiutano a mantenere invarianti ed esprimere il significato delle operazioni. Se invece limitano a esporre i campi uno-a-uno, ottieni un'interfaccia rumorosa che crea l'illusione di «incapsulamento» ma in realtà non protegge nulla — e spesso ostacola l'evoluzione della classe senza dolore.
Per non restare su un piano astratto, proseguiamo con la nostra applicazione console didattica TaskTracker (mini-tracker di task). Prima (alla fase di struct) un task poteva apparire così: id, title, done. Ora vogliamo fare un passo verso un «oggetto intelligente» che non si lascia rompere dall'esterno.
2. Getter: «permettere la lettura» è anche una responsabilità
Quando sentiamo «getter», solitamente immaginiamo: «serve per ottenere il valore di un campo». Ma nel design di una classe il getter non risponde solo della lettura, risponde anche di in quale forma il codice esterno vedrà i dati.
Questo è sorprendentemente importante: lo stesso fatto può essere «dato» in modo sicuro o pericoloso, economico o costoso, comodo o scomodo. In C++ abbiamo varie opzioni: restituire per valore, restituire const T&, e talvolta (con molta cautela) restituire T&. Ed è proprio sui getter che spesso iniziano le prime crepe nell'incapsulamento, perché «volevo solo comoditá» si trasforma facilmente in «ho accidentalmente dato accesso diretto all'interno».
Un getter semplice e sicuro per tipi piccoli
Per tipi piccoli come int, bool è quasi sempre comodo e sicuro restituire per valore:
class Task {
public:
int id() const { return id_; }
bool is_done() const { return done_; }
private:
int id_ = 0;
bool done_ = false;
};
Qui non ci sono insidie: int e bool si copiano velocemente, non si consegna accesso diretto ai campi interni, e gli invarianti non vengono compromessi.
Getter per std::string: valore o const&
Con le stringhe la cosa diventa più interessante. Se un task ha un titolo, si può fare così:
#include <string>
class Task {
public:
std::string title() const { return title_; } // copia
private:
std::string title_ = "untitled";
};
Questo è sicuro, ma può essere costoso: la stringa viene copiata. A volte va bene (stringhe corte, poche chiamate). Talvolta si vuole leggere la stringa senza copia — e allora si restituisce per riferimento const:
#include <string>
class Task {
public:
const std::string& title() const { return title_; } // senza copia
private:
std::string title_ = "untitled";
};
Questo è ancora sicuro per l'incapsulamento, perché const std::string& non consente al codice esterno di modificare direttamente la stringa. Ma compare una sottigliezza importante: il riferimento è «legato» all'oggetto. Se l'oggetto viene distrutto, il riferimento diventa pendente.
La regola è semplice: non conservare il risultato di un getter che restituisce const&
Perché un getter che restituisce T& spesso rompe la classe
Esempio di getter che sembra «molto comodo»:
#include <string>
class Task {
public:
std::string& title() { return title_; } // pericoloso
private:
std::string title_ = "untitled";
};
Ora il codice esterno può fare quello che vuole:
Task t;
// Il codice esterno ha ottenuto un riferimento modificabile e ha aggirato ogni controllo.
t.title().clear();
Se il vostro invariabile era «il titolo del task non deve essere vuoto», ora è stato violato. Formalmente la classe è «incapsulata» (il campo è private), ma di fatto avete fornito un collegamento diretto all'interno. Questo è quello che si chiama «perdita dell'incapsulamento».
Mini-tabella: come scegliere il tipo di ritorno in un getter
Quando iniziate a scrivere classi, è utile avere un orientamento semplice. Non una «regola», ma un buon senso per non impantanarsi su ogni metodo.
| Cosa restituiamo | Quando va bene | Vantaggi | Rischi |
|---|---|---|---|
| T (per valore) | tipi piccoli (int, bool) o quando la copia non è problematica | semplicità e sicurezza | può essere costoso per oggetti grandi |
| const T& | tipi grandi (std::string, std::vector) e letture frequenti | nessuna copia, ancora sicuro | non si può mantenere il riferimento oltre la vita dell'oggetto |
| T& | raro: quando consapevolmente permettete al codice esterno di modificare l'interno | massima «comoditá» | quasi sempre rompe invarianti e design |
Nota: in C++ il getter è parte dell'API. Se una volta avete restituito T&, avete quasi promesso all'utilizzatore della classe che potrà modificarlo direttamente per sempre. Dopo vorrete aggiungere controlli... e scoprirete che i controlli vengono aggirati via T&.
3. Setter e operazioni: come cambiare stato senza perdere gli invarianti
Lo setter sembra semplice: «assegnare un valore al campo». Ma in una classe corretta lo setter è un punto di controllo: qui decidete se il cambiamento è ammissibile, se normalizzare i dati, se rifiutare, e come mantenere gli invarianti.
Criterio principale: lo setter è giustificato quando esprime regole e significato. Se si limita a fare field_ = value; senza controlli, spesso si trasforma in «campo pubblico, ma tramite due schermate di codice».
Setter senza regole: formalmente c'è, di fatto — nulla viene protetto
#include <string>
class TaskBad {
public:
void set_title(const std::string& title) {
title_ = title; // senza controlli
}
const std::string& title() const { return title_; }
private:
std::string title_ = "untitled";
};
Cosa c'è di sbagliato? Avete dichiarato «controllo l'accesso», ma non controllate. Il codice esterno può impostare stringhe vuote, solo spazi, stringhe troppo lunghe — qualsiasi cosa. La classe non si protegge.
Setter con controlli: «setter con carattere»
Introduciamo un invariante: il titolo del task non deve essere vuoto.
#include <string>
class Task {
public:
bool set_title(const std::string& title) {
if (title.empty()) return false;
title_ = title;
return true;
}
const std::string& title() const { return title_; }
private:
std::string title_ = "untitled";
};
Ora lo setter è veramente utile: non si limita ad assegnare, ma non permette all'oggetto di diventare non valido.
Nota sullo stile: ritorniamo bool per far capire al codice esterno se l'operazione è riuscita o no. Non usiamo eccezioni (argomento ampio a parte), quindi un rifiuto «pulito» è pratica normale.
Perché «get/set per tutto» rende la classe anemica
L'errore più comune dei principianti suona così: «Dal momento che abbiamo class e private, mettiamo get/set per ogni campo». Sembra solido, ma se dentro i set_* non c'è logica né protezione degli invarianti, la classe diventa una versione rumorosa di struct.
Non aggiunge significato, ma aggiunge obblighi: ora il codice esterno dipende da decine di piccoli metodi, e cambiare la rappresentazione interna diventa più difficile.
Immaginate che il task abbia una priorità da 1 a 5. Se implementate set_priority(int p) senza controlli, permettete valori come -100 o 999. Poi nel programma inizieranno ad apparire «pezze» del tipo if (task.priority() < 1) ..., e avete perso: il controllo si è disperso in tutto il codice e la classe ha smesso di essere il «custode» della propria correttezza.
Metodi-operazione invece dei setter
Una buona abitudine: prima di scrivere uno setter, fermatevi un secondo e chiedetevi: «Questo è davvero un impostare un campo, o è un operazione?». Spesso è più leggibile e sicuro esporre un metodo-operazione.
Per esempio, invece di set_done(true) spesso è meglio:
- mark_done()
- reopen()
Si legge come un comando, non richiede di ricordare «che valore significa cosa», e internamente si possono garantire gli invarianti.
4. Esempio pratico: miglioriamo TaskTracker senza get/set inutili
Ora costruiamo un frammento più realistico per il nostro TaskTracker. I task avranno:
- id (non cambia dopo la creazione),
- title (non vuoto),
- done (cambia ma tramite metodi-operazione),
- priority (1..5).
Classe minima «corretta» Task
#include <string>
class Task {
public:
Task(int id, const std::string& title, int priority)
: id_(id), title_(title), priority_(priority) {
// In questa lezione non entriamo nei dettagli dei costruttori, ma l'idea è evidente.
// I controlli si possono fare anche qui, ma per ora manteniamo il focus sull'API.
if (title_.empty()) title_ = "untitled";
if (priority_ < 1) priority_ = 1;
if (priority_ > 5) priority_ = 5;
}
int id() const { return id_; }
const std::string& title() const { return title_; }
int priority() const { return priority_; }
bool is_done() const { return done_; }
bool rename(const std::string& new_title) {
if (new_title.empty()) return false;
title_ = new_title;
return true;
}
bool set_priority(int p) {
if (p < 1 || p > 5) return false;
priority_ = p;
return true;
}
void mark_done() { done_ = true; }
void reopen() { done_ = false; }
private:
int id_ = 0;
std::string title_ = "untitled";
int priority_ = 1; // invariante: 1..5
bool done_ = false;
};
Nota l'idea: non abbiamo creato set_id, perché id è l'identità del task. Se il codice esterno potesse cambiare id, si avrebbe rapidamente caos: il task «ieri era #3, oggi #17». A volte è accettabile, ma in quel caso non si tratta più di un id, bensì di «numero nella lista», cioè un'altra entità.
Usare la classe in un piccolo main()
#include <iostream>
#include <string>
int main() {
Task t(1, "Buy milk", 3);
std::cout << t.id() << ": " << t.title() << "\n"; // 1: Buy milk
t.mark_done();
std::cout << t.is_done() << "\n"; // 1
if (!t.rename("")) {
std::cout << "Bad title!\n"; // Bad title!
}
}
Sì, sembra un po' più verboso rispetto all'accesso diretto ai campi. Ma ora la «correttezza» è concentrata dentro Task, non dispersa nell'intero programma.
5. Perdita dell'incapsulamento: un esempio apparentemente innocuo
A volte si vuole «velocizzare» o «semplificare»: per esempio dare accesso al titolo del task come riferimento modificabile per poterlo editare direttamente. Mostriamo perché questo è pericoloso, soprattutto rispetto agli invarianti.
Cattiva variante:
#include <string>
class TaskLeaky {
public:
std::string& title() { return title_; } // abbiamo dato il controllo all'esterno
const std::string& title() const { return title_; }
private:
std::string title_ = "untitled"; // invariante: non vuoto
};
Uso:
#include <iostream>
int main() {
TaskLeaky t;
t.title().clear(); // l'invariante viene violato
std::cout << "[" << t.title() << "]\n"; // []
}
La classe non può più garantire la regola «non vuoto», perché avete costruito un tunnel che la aggira.
Variante corretta — lasciare solo la lettura e un metodo di modifica:
#include <string>
class TaskSafe {
public:
const std::string& title() const { return title_; }
bool rename(const std::string& new_title) {
if (new_title.empty()) return false;
title_ = new_title;
return true;
}
private:
std::string title_ = "untitled";
};
Ora si può modificare, ma solo tramite il punto di controllo rename, quindi con verifica.
6. Schema: come l'interfaccia della classe protegge lo stato
Talvolta aiuta vedere l'idea «a occhi»:
flowchart TD
A[Codice esterno] -->|chiama metodi pubblici| B[Interfaccia pubblica della classe]
B -->|controlli e regole| C[Campi privati]
A -->|se si restituisce T& esterno| C
Il senso è semplice: la via normale per cambiare stato passa dall'interfaccia pubblica, dove vivono i controlli. Ma se esponete un riferimento modificabile (T&) all'interno, il codice esterno comincerà ad accedere «direttamente» e le vostre regole resteranno disattivate.
7. Nomi di getter e setter: getTitle() vs title()
Nelle codebase incontrerete stili diversi. Nei esempi didattici spesso usiamo lo stile title() / priority() / is_done() — è breve e si legge come una «proprietà» senza rumore. Lo stile getTitle() non è sbagliato, soprattutto se il team o il progetto lo hanno deciso.
Ciò che conta è che il nome del metodo rifletta il significato. Se un metodo si chiama set_title ma internamente normalizza la stringa (trim degli spazi, sostituisce vuoto con "untitled"), forse è più logico chiamarlo rename o try_set_title, così il comportamento sia prevedibile.
Chiamare un metodo «set» e poi dentro «aggiungere se manca» è anch'esso una fonte comune di confusione.
Quando getter e setter sono davvero appropriati
Ci sono scenari in cui getter e setter sono ottimi strumenti e non vanno demonizzati.
Un getter è quasi sempre giustificato se il campo è private ma serve lettura sicura. Uno setter è giustificato se il campo ha veramente una «modalità di impostazione» e regole di correttezza. Nel nostro Task questo è set_priority, perché la «priorità» è un valore configurabile con un intervallo definito. Per done abbiamo scelto le operazioni mark_done/reopen, perché così si legge meglio e ci sono meno probabilità di confondere il significato.
8. Errori tipici
Errore n.1: «per ogni campo — get e set, perché si fa così».
Questo approccio spesso rende la classe rumorosa e inutile: gli invarianti non sono protetti, non c'è logica, e il codice esterno dipende da molti metodi piccoli. Se non aggiungete regole e significato, meglio lasciare una struct (dove è chiaro), oppure creare una vera classe con operazioni e controlli.
Errore n.2: setter senza controlli che lasciano passare valori non validi.
Quando un metodo si chiama set_*, il lettore si aspetta che dopo l'esecuzione l'oggetto rimanga valido. Se permettete di scrivere «qualsiasi cosa», i controlli proliferano nel resto del progetto e la classe perde il ruolo di custode dell'invariante. Meglio rifiutare (es. restituire false) che «accettare silenziosamente» spazzatura.
Errore n.3: getter che restituiscono T& perché «comodo».
Classico leak di incapsulamento: il codice esterno può modificare l'interno senza i vostri controlli. Particolarmente pericoloso con std::string& o std::vector<T>&, perché l'esterno può svuotare un container, aggiungere elementi strani, rompere relazioni fra campi. Se serve leggere senza copia — restituite const T&. Se serve modificare — esponete un metodo che rappresenti l'operazione e contenga i controlli.
Errore n.4: setter che cambiano l'«identità» dell'oggetto.
Metodi come set_id(...) possono sembrare innocui finché non iniziate a memorizzare oggetti in container, cercarli per id, generare report e collegare dati. Se l'id cambia, rompete facilmente la coerenza dell'applicazione. Di solito l'identità si fissa alla creazione e poi non si tocca.
Errore n.5: getter che restituiscono un riferimento e il chiamante lo conserva troppo a lungo.
Restituire const std::string& è comodo e veloce, ma il riferimento vive solo finché vive l'oggetto. Se da qualche parte conservate quel riferimento e poi l'oggetto viene distrutto o sostituito, otterrete accesso a «dati che non esistono più». Negli esempi didattici meglio attenersi alla regola: usare il riferimento del getter subito e non «tenerlo per dopo», se serve memorizzare — prendere una copia.
GO TO FULL VERSION