1. constexpr invece di macro-constanti
Quando vedi per la prima volta che #define MAX_USERS 100 «sembra funzionare», appare la tentazione: «Oh, si può fare tutto così!». E in quel momento da qualche parte piange un compilatore, perché i macro non sono entità C++ ma sostituzioni testuali. Non rispettano gli scope, non hanno tipo e possono cambiare il significato di un'espressione a causa delle precedenze degli operatori. In pratica i macro spesso assomigliano a un incantesimo potente: l'effetto è impressionante, ma il prezzo di un errore è una palla di fuoco sui propri file.
L'idea principale di questa lezione è semplice: se il compito può essere espresso con i meccanismi normali del C++ (costanti, funzioni, template), quasi sempre è meglio fare così. Lasciamo il macro per i casi in cui senza il preprocessore il senso realmente si perde.
Quando scriviamo un object-like macro, sembra una «costante»:
#define MAX_TASKS 200
Ma non è una costante. È una regola: «incontra MAX_TASKS — sostituisci il testo con 200». Tipo nessun tipo, nessuno scope, diagnostica che può essere strana.
Cosa dà constexpr nel ruolo di costante di progetto
La cosa più piacevole: constexpr è quasi altrettanto breve, ma è già una vera entità C++. Ha un tipo, è visibile/non visibile secondo le regole del C++, e il compilatore può dare messaggi d'errore più chiari.
Immaginiamo che stiamo sviluppando la nostra applicazione console didattica TodoLite (una lista di task fittizia). Ci serve una «configurazione»: il numero massimo di task che permettiamo di tenere in memoria.
Creiamo l'header include/app/config.hpp:
#pragma once
namespace app::config {
inline constexpr int MaxTasks = 200;
}
Qui ci sono subito due punti.
Primo, constexpr int è veramente un «intero», non testo.
Secondo, abbiamo aggiunto inline in modo che questa costante possa essere messa in un header e inclusa in più .cpp (per ora non entriamo nelle teorie di linking, limitiamoci alla regola pratica: «in header — pensa a inline»).
Ora in qualsiasi .cpp:
#include <iostream>
#include "app/config.hpp"
int main() {
std::cout << app::config::MaxTasks << '\n'; // 200
}
E questo non è più «magia della sostituzione», ma una normale costante tipizzata che vive nello spazio dei nomi app::config.
Perché è importante per i principianti
All'inizio sembra: «Che differenza c'è, 200 e 200». Ma la differenza emerge quando commetti un errore.
Se accidentalmente fai così:
#define MAX_TASKS "200"
Il compilatore può darti un errore lontano da dove te lo aspetti, perché in qualche punto invece di un numero si troverà un literal stringa.
Con constexpr questo non può succedere:
namespace app::config {
inline constexpr int MaxTasks = "200"; // errore di compilazione: tipo sbagliato
}
Il compilatore segnalerà l'errore esattamente nel punto: «non si può assegnare una stringa a int». Questa è una di quelle rare situazioni in cui il compilatore si comporta come un insegnante premuroso, non come un personaggio da horror.
3. Funzioni e template invece di function-like macro
Con i macro function-like la situazione diventa ancora più divertente, perché sembrano funzioni, ma non lo sono:
#define SQUARE(x) ((x) * (x))
Nella lezione precedente abbiamo già discusso che le parentesi sono obbligatorie. Ma anche con le parentesi resta un problema che non si può «curare con le parentesi»: l'argomento può essere valutato più volte.
Il problema della valutazione ripetuta
#include <iostream>
#define SQUARE(x) ((x) * (x))
int main() {
int i = 2;
int y = SQUARE(i++); // i++ verrà sostituito due volte
std::cout << "i=" << i << " y=" << y << '\n'; // i=4 y=6 (o altra "magia")
}
Qui non importa quale risultato esatto otterrai (può essere inatteso e dipendere dai dettagli). L'importante è che il significato dell'espressione diventa «scivoloso»: i++ improvvisamente viene eseguito due volte.
Sostituzione: funzione constexpr
Facciamo le cose in modo sensato:
constexpr int square(int x) {
return x * x;
}
E usiamola:
#include <iostream>
constexpr int square(int x) {
return x * x;
}
int main() {
int i = 2;
int y = square(i++); // i++ viene valutato una sola volta
std::cout << "i=" << i << " y=" << y << '\n'; // i=3 y=4
}
Il vantaggio di questa versione non è nemmeno tanto constexpr in sé. La cosa importante è che la funzione garantisce che l'argomento venga valutato una sola volta. È una grande vittoria sulla «magia testuale».
Esempio dal mini-progetto TodoLite: limitazione dell'intervallo
Ora facciamo un frammento più «pratico» per TodoLite, così si vede: non giochiamo solo con il quadrato, ma miglioriamo davvero il codice del progetto.
Immaginiamo che un task abbia priorità da 1 a 5, e vogliamo essere cauti: se l'utente inserisce 999 — limitiamo il valore a 5.
Un principiante spesso scrive un macro:
#define CLAMP(x, lo, hi) ((x) < (lo) ? (lo) : ((x) > (hi) ? (hi) : (x)))
Sembra potente, ma è pericoloso: gli argomenti possono essere valutati più volte (per esempio se contengono chiamate di funzione), e inoltre è difficile da leggere.
Sostituzione del macro: clamp come template di funzione
Quando vuoi fare una «cosa universale», il macro sembra comodo: è «testo». Ma qui il C++ fornisce uno strumento normale — il template di funzione.
Creiamo l'header include/app/util.hpp:
#pragma once
namespace app {
template <typename T>
constexpr T clamp(T x, T lo, T hi) {
if (x < lo) return lo;
if (x > hi) return hi;
return x;
}
} // namespace app
Nota due punti importanti.
- Primo, si legge come codice normale: if, return, niente ternaire nidificate che sembrano matrioske.
- Secondo, gli argomenti vengono valutati una sola volta, come in qualsiasi funzione.
Ora nel codice di TodoLite:
#include <iostream>
#include "app/util.hpp"
int main() {
int rawPriority = 999;
int priority = app::clamp(rawPriority, 1, 5);
std::cout << priority << '\n'; // 5
}
Ed è ancora compatto, ma molto più sicuro del macro.
Il template è parte del linguaggio C++. Partecipa al controllo dei tipi, rispetta gli scope, e fornisce errori più intelligenti rispetto alla «sostituzione testuale».
Sì, i messaggi di errore sui template a volte spaventano i principianti. Ma qui usiamo il template nella forma più semplice: «una funzione, un typename T». Per cominciare è sufficiente.
4. inline: come usare gli header senza macro
Quando il codice diventa multi-file, si presenta la situazione tipica: «voglio definire qualcosa in un header in modo che sia disponibile ovunque». I macro «risolvono» questo facilmente: definisci in .hpp — e tutto viene sostituito in ogni .cpp.
Ma i macro hanno un prezzo per questo: «trasudano» attraverso il progetto come acqua attraverso un tetto rotto. Oggi in un file va bene, domani includi un altro header — e all'improvviso MAX confligge con un altro MAX.
Modello pratico: inline constexpr per le impostazioni in config.hpp
Per TodoLite mettiamo alcune impostazioni in un unico posto:
#pragma once
namespace app::config {
inline constexpr int MaxTasks = 200;
inline constexpr int MinPriority = 1;
inline constexpr int MaxPriority = 5;
}
E nella logica di aggiunta task:
#include "app/config.hpp"
#include "app/util.hpp"
int normalizePriority(int raw) {
return app::clamp(raw, app::config::MinPriority, app::config::MaxPriority);
}
Ora tutto è tipizzato, tutto è in namespace, tutto sotto controllo.
inline-funzioni negli header
Se la funzione è piccola e vuoi tenerla nell'header (per esempio una utility di formattazione), spesso si fa così:
#pragma once
#include <string>
namespace app {
inline std::string yesNo(bool value) {
return value ? "yes" : "no";
}
} // namespace app
Qui inline significa: «questa funzione può essere definita in un header che viene incluso da molti .cpp». Non entriamo nelle regole formali; ci interessa la pratica: piccole funzioni-utility in header — sì, ma rendetele inline.
5. Quando il macro è comunque giustificato
Ci sono casi in cui il macro è davvero necessario, perché funziona nel punto di chiamata, non nel punto di definizione della funzione.
L'esempio classico è il logging con indicazione di file e linea: __FILE__ e __LINE__. Questi sono macro predefiniti che vengono sostituiti dal preprocessore e danno l'informazione «dove è stata chiamata questa istruzione». Questo comportamento è standardizzato e supportato da tutti i compilatori normali.
Ibrido: il macro cattura il punto di chiamata, il resto lo fa la funzione
Questo è un buon compromesso: minimizziamo il ruolo del macro a ciò che sa fare meglio — «inietta file e linea». Il resto della logica lo affidiamo a una funzione C++.
Per esempio, include/app/log.hpp:
#pragma once
#include <iostream>
namespace app {
inline void logLine(const char* file, int line, const char* msg) {
std::cout << file << ":" << line << " " << msg << '\n';
}
} // namespace app
#define APP_LOG(msg) ::app::logLine(__FILE__, __LINE__, (msg))
Il macro qui è piccolo e «stupido»: passa solo __FILE__ e __LINE__. Tutto il resto è normale C++.
Uso:
#include "app/log.hpp"
int main() {
APP_LOG("start"); // main.cpp:5 start (esempio)
APP_LOG("finish"); // main.cpp:6 finish (esempio)
}
Sì, è ancora un macro. Ma è un macro controllato: non cerca di essere una funzione, non fa calcoli, non contiene logica complessa.
6. Promemoria: con cosa sostituire un macro
A volte al principiante serve meno filosofia e più risposta rapida: «voglio scrivere un #define, cosa uso invece?». Facciamo una tabella rapida da tenere a mente.
| Cosa volevi fare con un macro | Opzione scorretta | Buona opzione in C++ | Perché è meglio |
|---|---|---|---|
| Costante (numero/limite) | |
|
Ha tipo, scope e diagnostica migliore |
| Calcolo semplice | |
|
L'argomento viene valutato una sola volta |
| Calcolo generico «per qualsiasi tipo» | |
|
I tipi sono controllati dal compilatore |
| «Operatore» multilinea | macro complesso | funzione normale / diverse funzioni | Leggibilità e debug |
| Serve sapere il punto di chiamata | «senza alternative» | piccolo macro + funzione | Il macro cattura __FILE__/__LINE__, la funzione fa il lavoro |
E ora lo schema di scelta — mini-flowchart:
flowchart TD
A[Voglio scrivere #define] --> B{È una 'costante'?}
B -->|Sì| C[inline constexpr / constexpr]
B -->|No| D{È un 'calcolo'?}
D -->|Sì| E{Serve per tipi diversi?}
E -->|No| F[constexpr/inline funzione]
E -->|Sì| G[template funzione]
D -->|No| H{Serve il punto di chiamata? __FILE__/__LINE__}
H -->|Sì| I[piccolo macro -> chiama funzione inline]
H -->|No| J[Probabilmente il macro non serve]
7. Errori tipici
Errore n.1: sostituire il macro con const, e poi ricevere sorprese in un progetto multi-file.
Un principiante rimuove #define, scrive nell'header const int MaxTasks = 200;, include quell'header in più .cpp e si sorprende per strani messaggi del linker o comportamenti ambigui. Il rimedio pratico a questo livello è semplice: per «config-const in header» usa inline constexpr e mettili in un file separato config.hpp.
Errore n.2: scrivere una funzione al posto del macro, ma dimenticare che a volte il macro cattura il «punto di chiamata».
Quando vuoi loggare «dove è successo», una funzione semplice log("msg") non conosce di per sé la riga e il file di chiamata: vedrà solo il posto in cui è definita. Perciò per __FILE__/__LINE__ è giustificato un ibrido: un piccolo macro che passa i metadati a una funzione normale.
Errore n.3: cercare di «fare come in C» e scrivere logica complessa in un macro perché è più corto.
Un macro di una riga può diventare un macro di tre righe, poi di dieci, poi inizi a temerlo e dopo un mese non riesci più nemmeno a leggerlo. Se dentro ci sono branche, cicli, più azioni e soprattutto lavoro con container, il macro è quasi certamente inappropriato: perdi i tipi, gli scope e una adeguata capacità di debug.
Errore n.4: usare i macro come «nomi globali», senza capire che ignorano il namespace.
Puoi scrivere con cura namespace app::config { ... }, e poi con un singolo #define MaxTasks ... rompere tutto, perché il macro si sostituisce in qualunque posizione del testo sotto, perfino dentro lo spazio di nomi di qualcun altro. Quindi la regola è semplice: nomina i macro in modo evidente (di solito ALL_CAPS) e limita la loro influenza; meglio sostituirli con constexpr/inline/template.
Errore n.5: sostituire un macro con un template e poi stupirsi per messaggi di compilazione «lunghi mezza pagina».
Sì, i template a volte producono messaggi verbosi. Ma in questa lezione li usiamo nel modo più semplice: un template <typename T> e logica semplice. Se l'errore spaventa, di solito aiuta semplificare la chiamata e verificare i tipi degli argomenti: il template clamp si aspetta che il tipo sia confrontabile con < e >, e magari hai passato tipi incompatibili o invertito gli argomenti.
GO TO FULL VERSION