1. Introduzione
Quando sentite per la prima volta la frase «l'oggetto dopo lo spostamento si trova in uno stato valido, ma non specificato», viene naturale chiedersi: «È come “vivo, ma senza documento”?» In sostanza — sì. La libreria standard formula proprio questo come contratto separato: gli oggetti moved-from devono rimanere validi, ma il loro contenuto concreto non è promesso. Anche nelle bozze dello standard questo è estratto come idea a sé (lib.types.movedfrom) e si sottolinea che è un principio generale per lo stato moved-from.
Suddividiamo l'enunciato in due parti.
«Valido» significa: l'oggetto è ancora un oggetto corretto del suo tipo. Il suo distruttore può essere chiamato in sicurezza. Può essere nuovamente assegnato/riinizializzato e ha l'obbligo di «tornare in vita» normalmente.
«Non specificato» significa: non si può fondare la logica del programma su ciò che esattamente è rimasto dentro. Può esserci «vuoto», può essere «quasi com'era», può essere «qualcosa di intermedio» — e questo non è un bug, ma una libertà concessa all'implementazione.
Una conclusione pratica molto importante: dopo move l'oggetto non va usato come sorgente di dati, ma si può usare come oggetto che si può distruggere, svuotare, riassegnare ecc.
2. Moved-from sull'esempio di std::string
Se avete imparato su esempi in cui std::string dopo std::move diventa stringa vuota, è normale: molte implementazioni lo fanno perché è comodo. Il problema è solo uno: non è una promessa, è una «abitudine di implementazione» diffusa.
Guardiamo un esempio che compila sempre, ma la stampa può variare:
#include <iostream>
#include <string>
#include <utility>
int main() {
std::string a = "hello";
std::string b = std::move(a);
std::cout << "b = " << b << '\n'; // b = hello
std::cout << "a.size() = " << a.size() << '\n'; // può essere 0, oppure no
}
E qui inizia la vita adulta.
a dopo lo spostamento:
- non è obbligatoriamente vuoto;
- non è obbligatoriamente che size() sia == 0;
- non è obbligatoriamente stampabile come stringa vuota (anche se spesso lo sarà).
Ma deve rimanere un oggetto al quale si può, per esempio, assegnare un nuovo valore:
#include <iostream>
#include <string>
#include <utility>
int main() {
std::string a = "hello";
std::string b = std::move(a);
a = "reused"; // questo deve funzionare correttamente
std::cout << "a = " << a << '\n'; // a = reused
}
Questa è la «validità»: l'oggetto può essere riportato a uno stato operativo normale con le normali operazioni del suo tipo.
3. Come definire lo stato moved-from nel proprio tipo
Se std::string può permettersi di «non specificare» il contenuto, il vostro tipo personalizzato, soprattutto didattico, può essere molto più amichevole. Ed è una buona pratica ingegneristica: trasformare l'oggetto moved-from in uno stato vuoto comprensibile e mantenere i suoi invarianti.
Proseguiremo con una risorsa manuale (un array di char), ma penseremo non solo al move, bensì al fatto che dopo move il nostro oggetto deve rimanere utilizzabile.
Invarianti dello stato vuoto
Buona pratica: scegliere invarianti semplici che siano facili da verificare a colpo d'occhio.
Per esempio, per un buffer di testo:
- se data_ == nullptr, allora size_ == 0;
- se size_ == 0, allora data_ o è nullptr, o punta a un buffer con '\0' (ma è meglio scegliere una variante e mantenerla).
Sceglieremo la variante didattica più semplice: oggetto vuoto = data_ == nullptr e size_ == 0.
Questo è comodo anche perché delete[] nullptr è sicuro: quindi il distruttore sarà semplice.
Mini-classe Text: move rende la sorgente vuota
Scheletro (senza ottimizzazioni complesse, ma prevedibile):
#include <cstddef>
#include <utility>
struct Text {
std::size_t size_{0};
char* data_{nullptr};
Text() = default;
explicit Text(std::size_t n) : size_(n), data_(new char[n + 1]{}) {
data_[n] = '\0';
}
~Text() { delete[] data_; }
Text(Text&& other) noexcept : size_(other.size_), data_(other.data_) {
other.size_ = 0;
other.data_ = nullptr;
}
Text& operator=(Text&& other) noexcept {
if (this == &other) return *this;
delete[] data_;
size_ = other.size_;
data_ = other.data_;
other.size_ = 0;
other.data_ = nullptr;
return *this;
}
};
Notate: le operazioni di move non «lasciano qualcosa a caso». Noi esplicitamente portiamo la sorgente allo stato vuoto. Questo rende lo stato moved-from non solo valido, ma anche comprensibile.
4. Cosa si può fare con un oggetto dopo move
Quando vi dicono «valido, ma non specificato», il cervello di solito chiede: «Significa “si può tutto, ma a proprio rischio” oppure “non si può nulla”?» In pratica è una terza via: si possono fare operazioni che non richiedono il significato dei vecchi dati.
Riepiloghiamo in tabella. (Non è magia del compilatore, ma disciplina del programmatore.)
| Azione su un oggetto moved-from | Di solito permesso? | Perché |
|---|---|---|
| Distruggere (uscire dallo scope) | Sì | Il distruttore deve essere sicuro |
| Assegnare un nuovo valore | Sì | Fa parte della «validità» |
| Spostarlo di nuovo | Sì | Lo spostamento da vuoto di solito è OK (resta vuoto) |
| Chiamare clear() / portarlo a stato vuoto | Sì | Non richiede i vecchi dati |
| Leggere il contenuto e basarsi sul suo significato | No | Il contenuto non è specificato |
| Fare if (x == "vecchio") (se esistesse ==) | No | Comparare con il «vecchio» non ha senso |
| Chiamare metodi che richiedono “non vuoto” (per esempio, front()) | Pericoloso | Se l'oggetto è vuoto, i prerequisiti non sono soddisfatti |
La chiave è: dopo move potete trattare l'oggetto come un “contenitore senza contenuto garantito”.
5. Invarianti e oggetti «semivuoti»
Un errore molto comune dei principianti: fare un move che lascia la sorgente «quasi vuota», ma non del tutto. Questo è peggio del semplice «non specificato», perché vi costruite da soli una trappola.
Per esempio, è sbagliato fare così:
Buffer(Buffer&& other) noexcept : size_(other.size_), data_(other.data_) {
other.data_ = nullptr;
// ci siamo dimenticati di azzerare other.size_
}
Perché è male? Perché other finirà nello stato:
- data_ == nullptr
- size_ > 0
E ora qualunque codice che si fida di size_ può provare a fare qualcosa e andare in crash. Questo non è «non specificato», è semplicemente progettazione scorretta.
Aggiungiamo al nostro Text piccoli metodi legati agli invarianti, rendendo così lo stato vuoto «ufficiale»:
#include <cstddef>
struct Text {
std::size_t size_{0};
char* data_{nullptr};
bool empty() const {
return size_ == 0; // secondo il nostro contratto questo è coerente con data_ == nullptr
}
void clear() {
delete[] data_;
data_ = nullptr;
size_ = 0;
}
};
Qui è importante che empty() guardi size_, mentre clear() porta entrambi i campi in uno stato coerente. È un passo diretto per fare in modo che l'oggetto moved-from non sia solo valido, ma anche «normalmente vuoto».
6. Come non usare un oggetto dopo move in un'applicazione
Immaginiamo (sullo stile dei nostri esempi didattici) un'app console «note-taking», dove una nota ha titolo e testo. Non costruiamo per ora un'architettura OOP complessa, mostriamo solo che il tipo utente può essere spostato in un contenitore e che la sorgente poi può essere riutilizzata.
Facciamo un modello minimale:
#include <utility>
struct Note {
Text title;
Text body;
};
int main() {
Note n;
n.title = Text{5}; // memoria per 5 caratteri, indicativo
n.body = Text{20};
Note saved = std::move(n); // n ora è moved-from
// non si può leggere n.body.size_ come "lì ci sono 20"
n.title = Text{3}; // ma si può "riinizializzare" e riutilizzare
}
Qui l'importante non è la funzionalità delle note, ma l'abitudine:
- appena scrivete saved = std::move(n), n non è più sorgente di dati;
- ma n resta un oggetto che si può usare dopo riassegnazione/riinizializzazione.
Per fissare l'idea, è utile tenere a mente questo «diagramma di vita»:
stateDiagram-v2
state "Normal" as N
state "MovedFrom" as M
[*] --> N: creato e riempito
N --> M: std::move x
M --> N: assegnazione o clear e riempimento
N --> [*]: distruttore
M --> [*]: distruttore
Il punto è che moved-from non è «rotto» né «semimorto». È semplicemente uno stato in cui il significato dei vecchi dati non è garantito.
7. Perché lo standard sceglie “valid but unspecified” e come conviverci
Perché lo standard sceglie “valid but unspecified”
Questa parte suona filosofica, ma in pratica è molto utile. Se lo standard imponesse che l'oggetto moved-from fosse, per esempio, obbligatoriamente vuoto, allora le implementazioni a volte dovrebbero fare lavoro extra per portarlo a una forma concreta.
La formulazione «valido, ma non specificato» dà all'implementazione il diritto di:
- rendere lo spostamento il più economico possibile;
- lasciare le strutture interne in qualsiasi stato corretto;
- non fornire garanzie inutili dove intralciano.
Nei documenti del committee questo è sottolineato come principio generale: lo stato moved-from è descritto come «valid but unspecified» ed è estratto come norma separata per la libreria.
Per voi (come autori di tipi personalizzati) questo significa: potete fare lo stesso, ma nei tipi didattici e applicativi spesso conviene offrire un contratto più amichevole — «dopo move l'oggetto è vuoto».
Attenzione alla sottigliezza: «vuoto» è già la vostra specificazione. Avete il diritto di rendere lo stato moved-from più definito di quanto minimo richiesto. L'importante è non promettere cose che non mantenete.
Set minimo di operazioni per un oggetto moved-from
Per rendere i vostri tipi comodi, vale la pena verificare mentalmente questo set minimo:
- l'oggetto può essere distrutto in qualsiasi stato
- l'oggetto può essere portato a stato vuoto (esplicitamente o implicitamente)
- all'oggetto si può assegnare un nuovo valore (per copia o per move)
- i metodi che non richiedono dati (per esempio empty()) funzionano sempre
Suona noioso, ma è come la cintura di sicurezza: raramente la si ringrazia, ma allunga la vita.
Abbiamo già implementato clear() e empty() per Text. Aggiungiamo un «accesso sicuro» ai dati: se l'oggetto è vuoto, restituiamo "" (stringa C vuota). Non è il design perfetto per ogni caso, ma per un tipo didattico è molto illustrativo:
struct Text {
std::size_t size_{0};
char* data_{nullptr};
const char* c_str() const {
return data_ ? data_ : "";
}
};
Ora anche un Text moved-from si può stampare (pur senza garanzie sul senso — ma almeno senza crash).
8. Errori tipici nel lavoro con lo stato moved-from
Errore №1: usare l'oggetto dopo std::move come se nulla fosse successo.
Lo scenario più comune: spostate x in y, poi leggete x e vi basate logicamente sui suoi «vecchi» dati. Il codice può «funzionare» sulla vostra macchina e poi cambiare comportamento su un'altra libreria o dopo un aggiornamento del compilatore, perché il contenuto del moved-from non è garantito.
Errore №2: progettare lo stato moved-from in modo incoerente.
Se il vostro tipo ha campi correlati (size e data), e dopo il move azzerate solo uno di essi, ottenete un oggetto formalmente «vivo», ma qualsiasi funzione che si fida degli invarianti può rompersi. Un move corretto termina quasi sempre portando la sorgente in uno stato «vuoto» coerente.
Errore №3: scrivere metodi che non reggono lo stato vuoto e non dichiarare prerequisiti.
Immaginate un metodo front() o at(0). Se dopo il move l'oggetto può diventare vuoto, questi metodi devono o gestire correttamente il caso, o avere un chiaro prerequisito «l'oggetto non è vuoto». La versione da principiante è «tanto si sistema da sola», e «si sistema da sola» in C++ di solito significa «alle 3 di notte».
Errore №4: fidarsi della stampa/log del moved-from come diagnostica di correttezza.
A volte si fa <std::cout << x>> per verificare che dopo move x sia vuoto — e si considera questa la prova di correttezza. È un brutto test: controllate un caso particolare di una implementazione concreta, non il contratto. Meglio verificare gli invarianti: «non crasha», «si può riassegnare», «si può distruggere», empty() è coerente.
Errore №5: promettere più di quanto si garantisce.
Potete rendere lo stato moved-from «vuoto» e documentarlo come parte del vostro contratto — ottimo. Ma non scrivete nel commento «dopo move i dati sono preservati» e poi «prendete gli interni». Un commento non è il compilatore: i bug li trova il prossimo sviluppatore, e lui non sarà felice.
GO TO FULL VERSION