Membri const e mutable in C++: perché l’immutabilità conta
Nel grande panorama di C++, “const” è una di quelle parole chiave che ricorre in continuazione, tanto da suscitare una comprensibile curiosità in chi si avvicina al linguaggio. In apparenza, potrebbe sembrare un semplice strumento per dichiarare variabili che non cambiano, ma in realtà l’uso di const
si estende ben oltre, soprattutto quando si parla di classi e, più nello specifico, di “membri const” e del misterioso aggettivo “mutable”. L’obiettivo di questo testo è illuminare in modo chiaro e informale come funzionano i membri const, qual è il loro significato a livello di progettazione e come l’attributo mutable si innesti in questo contesto, permettendo alcune eccezioni alla regola d’oro dell’immutabilità.
Attraverso esempi e riflessioni, vedremo che dichiarare un membro come const non si limita a rendere “fisso” un dato, ma comunica anche una forma di “intenzione” a chi legge il codice. Al contempo, scopriremo come mutable
spezzi la rigidità del const, favorendo scenari in cui un oggetto può considerarsi logicamente immutabile, pur modificando alcune informazioni di servizio. Il tutto in linea con lo spirito di C++, un linguaggio dove si cerca di unire prestazioni, controllo fine e chiarezza semantica.
Cost e la garanzia di immutabilità
In C++, la parola chiave const
può apparire in vari punti, assumendo sfumature diverse a seconda del contesto. Il senso generale, però, è “non modificabile”. Un membro dati marcato const non può variare dopo la sua inizializzazione. Se una classe dichiara un campo come const int valore;
, significa che una volta assegnato, valore
rimane fermo. Non si può cambiare in una successiva fase dell’esecuzione.
Un esempio molto semplice:
class Esempio {
public:
const int numero;
Esempio(int n) : numero(n) {}
};
Qui numero
è cost. Il costruttore assegna numero
in base all’argomento ricevuto, dopodiché non sarà più possibile alterarlo. Non c’è bisogno di particolari blocchi di codice per impedire l’assegnazione, perché il compilatore non ci consentirebbe di scrivere nulla come numero = 10;
in un metodo successivo. L’errore sarebbe immediato a tempo di compilazione.
È opportuno ricordare che se un membro è const, va inizializzato nella lista di inizializzazione del costruttore, e non nel corpo. Non si può assegnare un valore a un campo const dopo che l’oggetto ha iniziato a vivere: la linea di demarcazione è netta.
Se poi definiamo un metodo cost in una classe, scrivendo qualcosa tipo int getValore() const { … }
, stiamo dicendo che quella funzione non modifica lo stato logico dell’oggetto. È una promessa al compilatore (e ai lettori del codice) che all’interno del metodo non avverrà alcuna alterazione dei campi dell’istanza, almeno di quelli non marcati come eccezione (che vedremo essere mutable
). Dichiarare un metodo const
significa anche che questo può essere invocato su oggetti const, cioè su istanze che lo sviluppatore desidera tenere al riparo da variazioni.
Metodi const e stato logico dell’oggetto
Il concetto di metodo const
invita a riflettere su cosa significhi “non mutare lo stato”. In molti casi è intuitivo: un metodo getter che restituisce un valore intero non dovrebbe cambiare i campi dell’oggetto, quindi può essere segnato come const senza problemi. Ma ci sono situazioni più sfumate. Pensiamo a una classe che memorizza una cache di risultati per accelerare certi calcoli. Da un punto di vista logico, l’oggetto potrebbe restituire lo stesso risultato anche se aggiorna la cache internamente, pur restando “equivalente” sul piano funzionale. Eppure, cambiare la cache significa modificare in qualche modo un campo. È in un contesto simile che emerge l’utilità di un membro mutable
.
L’eccezione mutable: scavalcare la regola del const
mutable
è una keyword meno nota, ma risponde proprio alla necessità di consentire modifiche interne a uno specifico membro anche in un metodo marcato const
. Segnando una variabile con mutable
, diciamo al compilatore: “Benché l’oggetto possa essere considerato cost dal punto di vista funzionale, questo particolare campo resta modificabile, magari perché è una cache, un contatore di statistiche o un dettaglio di implementazione che non intacca la visione logica dell’oggetto.”
Un esempio più concreto chiarisce il tutto:
class Cache {
private:
int valoreBase;
mutable int risultatoCache;
mutable bool cacheValida;
public:
Cache(int base) : valoreBase(base), risultatoCache(0), cacheValida(false) {}
int calcoloComplesso() const {
if (!cacheValida) {
// Simula un'operazione costosa
risultatoCache = valoreBase * 10;
cacheValida = true;
}
return risultatoCache;
}
};
Notiamo come calcoloComplesso()
è dichiarato const: promette di non cambiare lo stato “logico” dell’oggetto. In realtà, nel corpo di quel metodo stiamo modificando risultatoCache
e cacheValida
. Senza la dichiarazione mutable
, questa operazione verrebbe considerata illegale: non si può toccare un campo dell’oggetto da un metodo const, verrebbe segnalata come violazione di “const correctness”. Con mutable
, invece, segnaliamo che queste variabili costituiscono un aspetto secondario o “interno” dell’oggetto, di cui l’utente potrebbe anche non accorgersi. L’utente vede “sempre la stessa” entità in termini di dati fondamentali, ma intanto la cache può fare il proprio lavoro.
Cost correctness e design robusto
Un aspetto cruciale, quando si adotta const in C++, è la cosiddetta “cost correctness”: un metodo dichiarato const non deve, in linea di principio, alterare lo stato osservabile dall’esterno. Se si contraddice questa regola, si crea confusione o si infrange la semantica di immutabilità. Per evitare questi problemi, la keyword mutable
va impiegata con giudizio. Se la si usa per cambiare davvero un dato centrale dell’oggetto, si tradisce in qualche modo l’idea alla base dei metodi const, e si generano possibili incomprensioni per chi legge il codice.
Tuttavia, in circostanze come la cache o i contatori di accesso, mutable
consente di conservare un’interfaccia di sola lettura (const) pur aggiornando informazioni accessorie, come quante volte è stato invocato un metodo. È una grande risorsa in contesti di debugging, analisi delle prestazioni o ottimizzazioni interne.
Dichiarare campi const dentro la classe
La differenza tra un metodo const e un membro dati const è sottile ma importante. Un metodo const descrive come la funzione si comporta rispetto alla classe (non cambia i campi, a meno che non siano mutable), mentre un campo const è un dato che rimane uguale dal momento in cui è inizializzato fino alla distruzione dell’oggetto. Se un programmatore scrive:
class Config {
public:
const int maxConnessioni;
Config(int c) : maxConnessioni(c) {}
};
maxConnessioni
è stabilito una volta per tutte nel costruttore e non potrà mai essere modificato dopo. Questo approccio risulta comodo se l’oggetto Config
rappresenta parametri che rimangono fissi per tutto il ciclo di vita. All’interno di un metodo successivo della classe, tipo:
void aggiornaConnessioni(int nuovoValore) {
// maxConnessioni = nuovoValore; // Errore: non consentito
}
Se ci provassimo, riceveremmo un chiaro messaggio di errore dal compilatore. Questo a volte viene usato come forma di autodocumentazione: “guarda che, in questa classe, il campo maxConnessioni
non cambia mai.”
Costanti vere dentro la classe: non solo variabili base
Spesso si pensa a un membro const come a un semplice intero o double, ma nulla vieta di inserire una costante di tipo più complesso, come un std::string
o un oggetto di una classe personalizzata, purché si possa costruire in modo const. Per esempio:
class MessaggioFisso {
private:
const std::string testo;
public:
MessaggioFisso(const std::string& t) : testo(t) {}
void stampa() const {
std::cout << testo << std::endl;
}
};
testo
è un std::string
cost. Cambiare il suo contenuto dopo la costruzione non è ammesso. Da un certo punto di vista, è come avere un testimone immutabile che custodisce un testo definito in fase di creazione. Naturalmente, se std::string
fosse modificabile dall’interno, potrebbe esistere qualche meccanismo di scrittura segreta, ma la semantica dell’oggetto ci indica che le sue funzioni che mutano i contenuti non potrebbero essere invocate su un’istanza const
di std::string
.
Quando i metodi const ritornano puntatori o riferimenti
Un’area delicata è rappresentata dai metodi const che restituiscono riferimenti o puntatori a membri dati non const. È un modo subdolo per aggirare la protezione dell’immutabilità. Immaginiamo:
class StranaClasse {
private:
int valore;
public:
const int& getValore() const {
return valore;
}
};
Se getValore()
restituisce un riferimento const, si conserva l’intenzione di impedire la modifica esterna. Ma se restituissimo un riferimento non const, come int&
, chi invoca getValore()
su un oggetto const potrebbe di fatto modificare l’interno dell’oggetto, infrangendo la semantica. Il compilatore, fortunatamente, non lo permetterebbe se l’oggetto stesso è const (perché non si può ottenere un riferimento non const da un metodo const), ma se l’oggetto non è const potrebbe esserci libertà totale. In altre parole, restituire riferimenti non const ai campi interni tende a esporre dettagli dell’implementazione in maniera pericolosa, riducendo i benefici dell’incapsulamento.
Metodi const e polimorfismo
Potremmo chiederci come si comportino i metodi const in caso di ereditarietà e polimorfismo. Se in una classe base esiste:
class Base {
public:
virtual void metodo() const {
std::cout << "Base const" << std::endl;
}
};
e in una derivata si ridefinisce:
class Derivata : public Base {
public:
void metodo() const override {
std::cout << "Derivata const" << std::endl;
}
};
allora, usando un puntatore o un riferimento polimorfo a Base
, se invochiamo metodo()
, verrà richiamata la versione di Derivata
per le istanze di quest’ultima. Funziona come ci si aspetterebbe, con la differenza che qui la firma del metodo include const
. Non è differente, in sostanza, da come il polimorfismo si comporta con metodi non const, purché ci sia corrispondenza di firma.
C’è un dettaglio interessante: se nella classe base esiste un metodo non const e nella derivata si definisce una versione const con la stessa nomea, in realtà è un overload, non un override. C++ distingue le firme sulla base del const. Quindi, per ridefinire un metodo come polimorfico, occorre mantenere la stessa cost correctness nella firma.
mutable e la gestione di contatori o di flag interni
Uno degli scenari più frequenti in cui mutable
brilla è la gestione di contatori interni. Mettiamo di avere una classe che effettua ricerche, e vogliamo registrare quante volte è stato richiamato un certo metodo di sola lettura:
class Ricercatore {
private:
std::vector<int> dati;
mutable int accessi;
public:
Ricercatore(const std::vector<int>& v) : dati(v), accessi(0) {}
int getElemento(size_t index) const {
accessi++;
return dati.at(index);
}
int getAccessi() const {
return accessi;
}
};
getElemento()
non altera la logica dei dati di dati
, quindi è plausibile etichettarlo come const. Eppure, incrementiamo accessi
. Se non fosse marcato mutable
, avremmo un errore di compilazione. Con mutable
, si esplicita che accessi
non è parte dello stato logico dell’oggetto; è una statistica che possiamo alterare senza violare la regola di non modificare i dati, o meglio, di non modificare quelli “essenziali” dell’oggetto. Così chiunque legga capisce l’intenzione: i dati in dati
restano intatti, ma accessi
può salire liberamente.
Punti di contatto con la programmazione funzionale
In certi paradigmi funzionali, l’immutabilità è una caratteristica essenziale: gli oggetti non cambiano mai il loro stato, e questo riduce gli errori e semplifica la concorrenza. In C++, non è un principio di base, ma si può replicare in parte, marcando i campi come const e i metodi come const. Ciò significa che, per certe classi, si possono scrivere costruttori che inizializzano i campi e poi trattare la classe come immutabile.
I risultati possono essere interessanti anche sul piano dell’ottimizzazione, perché il compilatore sa che i valori non mutano. Tuttavia, C++ non forza questo paradigma: mutable
è lì, a sottolineare che in alcune situazioni l’immobilità apparente non è totale, e rimane la possibilità di scavalcare la regola. Sta al progettista decidere in che misura adottare un approccio “immutabile” e se deve estendersi a tutta la classe o solo a parte dei dati.
Differenza tra const e #define o enum
Storicamente, in C si usava spesso #define
per dichiarare costanti precompilate, oppure enumerazioni per definire valori di costanti simboliche. In C++, l’uso di cost come parola chiave per i membri di classe si rivela più sicuro e meglio integrato nel linguaggio. I #define
non rispettano gli spazi di nome, non generano simboli con un tipo preciso, e spesso sfociano in potenziali collisioni. Dichiarare un membro static const
o constexpr
nella classe è una pratica più moderna e robusta.
L’enum resta un’opzione valida quando si vuole definire un elenco di costanti correlate. Ma se l’intento è stabilire un singolo valore cost a livello di classe, l’approccio più chiaro rimane quello di utilizzare un membro const
(o constexpr
). Le enumerazioni entrano in gioco soprattutto quando si desidera un insieme di costanti fortemente collegato, magari con un senso di enumerazione progressiva.
Altri aspetti: oggetti const e binding implicito
Un oggetto di una classe può essere dichiarato come const dall’esterno, e in quel caso non possiamo invocarne i metodi non const. Se si ha:
Esempio oggetto(10); // oggetto normale
const Esempio oggettoConst(5); // oggetto cost
oggetto.faiQualcosa(); // ok, invoca un metodo non const
oggettoConst.faiQualcosa(); // errore, a meno che faiQualcosa() sia const
Ciò obbliga chi progetta la classe a fornire metodi const per le operazioni che non cambiano il senso dell’oggetto, così da renderle disponibili anche su istanze const. Se la classe non offre versioni const di tali metodi, su un oggetto const si potranno chiamare solo i metodi static o le funzioni friend che non richiedono un riferimento non const a this
.
Un esempio di classe immutabile con eccezioni
Per concludere la disamina, può essere utile mostrare una classe che si dichiari “immutabile” dal punto di vista logico, ma che utilizzi mutable
per scopi interni. Costruiamo un oggetto “TestoImmutabile” che custodisce una stringa fissa ma tiene traccia di quante volte la stringa viene letta:
#include <iostream>
#include <string>
class TestoImmutabile {
private:
const std::string contenuto;
mutable int letture;
public:
TestoImmutabile(const std::string& txt)
: contenuto(txt), letture(0) {}
std::string getTesto() const {
letture++;
return contenuto;
}
int getNumeroLetture() const {
return letture;
}
};
int main() {
TestoImmutabile t("Esempio di testo");
std::cout << t.getTesto() << std::endl;
std::cout << t.getTesto() << std::endl;
std::cout << "Chiamate di getTesto: " << t.getNumeroLetture() << std::endl;
return 0;
}
contenuto
non cambia mai dopo l’inizializzazione, perché è const. L’oggetto appare totalmente immutabile all’esterno. Tuttavia, ogni volta che si invoca getTesto()
, aggiorniamo letture
, marcato mutable
. L’incremento avviene in un metodo const, ma il compilatore lo accetta proprio perché letture
non partecipa alla definizione logica dello stato (in termini di testo conservato). Alla fine, l’utente potrà vedere quante volte si è letto il contenuto, senza che ciò intacchi la sensazione di avere un oggetto “immutabile” dal punto di vista dei dati fondamentali.
La combinazione di membri const e keyword mutable costituisce una parte essenziale della filosofia di C++ sul controllo fine di ciò che può e non può cambiare nell’oggetto. Dichiarare un campo come const crea un vincolo ferreo, che obbliga chi scrive codice a pensare in anticipo allo stato iniziale dell’oggetto e alla sua eventuale evoluzione. Marcando un metodo come const si lancia il segnale: “Questo non cambia l’oggetto”, e il compilatore veglierà su questa promessa. Da parte sua, mutable è la scappatoia controllata: un modo di dire, “Ok, in realtà in certe circostanze ho bisogno di modificare un dettaglio che non inficia la semantica generale.”
Spesso ci si rende conto di quanto sia cruciale questo meccanismo quando si vogliono realizzare classi davvero a prova di errore, soprattutto in ambienti multi-thread o in librerie complesse. Se un utente crea un oggetto e lo passa come const a una funzione, si aspetta che quel valore non venga alterato da quell’interfaccia, a meno che non ci sia un valido motivo (che, appunto, potrebbe essere incapsulato in un mutable
). È un patto di fiducia che semplifica la lettura e la sicurezza del codice, innescando ottimizzazioni e best practice di design.
I programmatori che arrivano da altri linguaggi più dinamici, dove l’immutabilità è meno stringente, trovano a volte la sintassi di C++ su const e mutable un po’ rigida. Eppure, una volta colti i benefici di poter contare su questa distinzione così nitida, è facile apprezzare la cost correctness come uno dei pilastri che rende C++ affidabile in molti scenari.
Non esiste una ricetta magica per decidere sempre cosa rendere const e quando usare mutable. Tuttavia, un buon punto di partenza è pensare al comportamento “naturale” dell’oggetto: se la classe rappresenta un’entità che deve mantenere un valore fermo (un identificatore, un parametro di configurazione), cost è perfetto. Se invece c’è un aspetto “nascosto” che può variare in maniera trasparente (una cache o un contatore di debug), allora mutable è la chiave.
In definitiva, i membri const sono una dichiarazione di intenti: “Questo pezzo di informazione non cambierà dopo la costruzione.” I metodi const ribadiscono che “L’azione eseguita non altera lo stato interno essenziale dell’oggetto.” Il qualificatore mutable, al contrario, è lo spiraglio che permette a certe parti interne di modificarsi ugualmente. Il linguaggio lascia quindi aperta la porta a soluzioni raffinate, a patto che si maneggi con cura questa libertà. E, nel solco della tradizione di C++, la combinazione tra regole di ferro (const) e deroghe circoscritte (mutable) si traduce in un codice robusto ma non soffocante, dove la chiarezza e il controllo rimangono i cardini su cui costruire grandi progetti.