In C++17, lo standard ha introdotto l'eliminazione della copia garantita (eliminazione della copia obbligatoria), che cambia fondamentalmente il modo in cui vengono materializzati i prvalue (valori puri rvalue). Quando un prvalue di tipo classe inizializza un oggetto dello stesso tipo—come quando si restituisce un valore da una funzione o si passa un temporaneo a una funzione—l'oggetto è costruito direttamente nella memoria di destinazione. Di conseguenza, il costruttore di copia o il costruttore di movimento non viene invocato e, cosa importante, né la loro accessibilità (pubblica vs. privata) né la loro mera esistenza (purché la classe sia completa e distruttibile) sono richieste affinché l'operazione sia ben formata. Questo contrasta nettamente con gli standard precedenti in cui l'eliminazione era semplicemente un'ottimizzazione opzionale che richiedeva comunque costruttori accessibili e presenti per la compilazione.
struct Immovable { Immovable() = default; Immovable(const Immovable&) = delete; Immovable(Immovable&&) = delete; }; Immovable factory() { return Immovable{}; // OK in C++17: nessun movimento/copia invocato } void consume(Immovable x); // Parametro inizializzato direttamente da prvalue
Il nostro team stava costruendo un driver in modalità kernel dove i gestori di risorse che avvolgono contesti hardware non potevano essere duplicati o spostati in memoria a causa degli indirizzi di kernel registrati. Avevamo bisogno di una funzione factory per produrre questi gestori per la gestione RAII per valore, ma i gestori avevano esplicitamente eliminato sia i costruttori di copia che di movimento per prevenire l'invalidazione accidentale delle mappature del kernel. Prima di C++17, questo design era incompatibile con il ritorno per valore perché anche con NRVO, il compilatore richiedeva concettualmente che il costruttore di movimento fosse accessibile, risultando in errori di compilazione.
Soluzione 1: Allocazione su heap tramite std::unique_ptr
Consideravamo di avvolgere il gestore in un std::unique_ptr, permettendo al puntatore di essere spostato mentre l'oggetto sottostante rimaneva bloccato. Questo approccio forniva sicurezza e funzionava in C++14.
Pro: Gestione della memoria standard, previene perdite, ampiamente supportato in base di codice legacy.
Contro: Introduce un sovraccarico di allocazione dinamica e indirection del puntatore, che è proibitivo nei contesti kernel dove è richiesta una bassa latenza deterministica; inoltre frammenta la cache della CPU e richiede considerazioni sulla gestione delle eccezioni per il fallimento dell'allocazione.
Soluzione 2: Inizializzazione del parametro di uscita
Passaggio di un riferimento a un oggetto allocato dal chiamante nella factory per essere inizializzato in loco.
Pro: Garanzia di zero-copy indipendentemente dalla versione dello standard C++; nessuna allocazione su heap; compatibile con tipi immovibili.
Contro: Distrugge lo stile API fluente (auto h = create(); diventa Handle h; create(h);); aumenta il rischio di uso prima dell'inizializzazione e si compone male con algoritmi standard e cicli for basati su intervallo.
Soluzione 3: Sfruttare l'eliminazione della copia garantita di C++17
Abbiamo rifattorizzato la factory per restituire il tipo immovibile per valore, facendo affidamento sull'eliminazione obbligatoria per costruire il prvalue direttamente nella memoria del chiamante.
Pro: Elimina l'uso dell'heap; preserva la semantica del valore; impone l'astrazione a costo zero a tempo di compilazione; i costruttori di movimento/copia non devono esistere né essere accessibili.
Contro: Si applica strettamente ai valori puri rvalue (non può restituire variabili nominate esistenti); richiede un compilatore con supporto C++17; differenze sottili nella gestione delle eccezioni durante la costruzione devono essere comprese.
Abbiamo selezionato la Soluzione 3 perché la factory produceva temporanei freschi che erano puri prvalues, perfettamente corrispondenti allo scenario di eliminazione garantita. Questo ha permesso ai gestori di rimanere rigorosamente immovibili mentre si mantenevano le semantiche del valore ergonomiche e la compatibilità con le dichiarazioni auto.
Il driver è stato spedito con un'inizializzazione dell'ordine di microsecondi per migliaia di connessioni concorrenti. L'ispezione assembly ha confermato che il gestore era stato costruito direttamente nel frame di stack del chiamante senza alcun codice di rilocazione o copia. Il sistema di tipi ha imposto la sicurezza delle risorse per costruzione, ed abbiamo eliminato completamente la contesa dell'heap dal percorso critico.
L'eliminazione della copia garantita si applica ai valori di ritorno nominati (lvalues) all'interno della funzione, o è strettamente limitata ai prvalues?
L'eliminazione della copia garantita si applica esclusivamente ai prvalues (valori puri rvalue), come i temporanei creati nella dichiarazione di ritorno senza un nome. L'ottimizzazione per il valore di ritorno nominato (NRVO) rimane un'ottimizzazione del compilatore opzionale; pur essendo ampiamente implementata, non fornisce le stesse garanzie riguardo l'accessibilità del costruttore o effetti collaterali. Se un candidato tenta di restituire una variabile locale nominata e presume che attiverà l'eliminazione garantita anche se il costruttore di movimento è eliminato, il programma sarà mal formato perché le variabili nominate sono lvalues e richiedono operazioni di movimento/copia a meno che il compilatore non applichi l'opzionale NRVO, che non è obbligata.
Un classe con costruttori di copia e movimento esplicitamente eliminati può essere restituita per valore da una funzione sotto le regole di eliminazione della copia garantita?
Sì. In C++17, se l'espressione restituita è un prvalue (ad esempio, return MyClass{};), i costruttori di copia e di movimento non vengono mai considerati per l'inizializzazione. Poiché l'oggetto è costruito direttamente nella memoria del chiamante, i costruttori eliminati non vengono utilizzati e non causano errori di compilazione. Tuttavia, tentare di restituire una variabile nominata di un tale tipo fallirà, poiché quell'operazione richiede concettualmente di muovere l'lvalue nello slot di ritorno, il che attiverebbe il costruttore di movimento eliminato e risulterebbe in un programma mal formato.
Come interagisce l'eliminazione della copia garantita con la sicurezza delle eccezioni, specificamente riguardo la vita temporanea del prvalue durante lo sblocco dello stack?
Sotto l'eliminazione della copia garantita, non viene creato un oggetto temporaneo separato prima che inizi la vita dell'oggetto target. Il prvalue è materializzato direttamente nella sua destinazione finale. Di conseguenza, se si verifica un'eccezione durante la costruzione del prvalue, il meccanismo di sblocco dello stack non incontra un temporaneo separato che richiede distruzione; invece, vede l'oggetto di destinazione parzialmente costruito. Questo significa che, dalla prospettiva del chiamante, l'oggetto esiste o è completamente costruito o non esiste affatto, semplificando le garanzie di sicurezza delle eccezioni e assicurando che non si verifichino distruzioni doppie o perdite di risorse a causa di un temporaneo abbandonato durante la gestione delle eccezioni prima che la vita dell'oggetto di destinazione inizi ufficialmente.