CodeGym /Corsi /C++ SELF /Passaggio di std::unique_ptr alle funzioni

Passaggio di std::unique_ptr alle funzioni

C++ SELF
Livello 43, Lezione 0
Disponibile

1. Introduzione

Quando iniziate a scrivere funzioni, si vorrebbe che tutto fosse uniforme: «passi l'argomento — la funzione fa il suo lavoro — restituisce il risultato». Ma con std::unique_ptr c'è una sottigliezza: non è semplicemente un «puntatore», è un contratto di proprietà. E il contratto è rigoroso: se due partecipanti pensano di «possedere lo stesso», prima o poi si verificherà una doppia deallocazione (o qualche altro tipo di guaio).

È importante accettare mentalmente un concetto: il tipo del parametro deve raccontare la storia. Non in un commento «// qui si trasferisce la proprietà», ma direttamente nella firma. In buon codice C++ spesso potete capire «chi possiede l'oggetto dopo la chiamata» guardando solo il tipo del parametro. E non è estetica — è un modo per non inseguire bug del tipo «crasha una volta alla settimana sul PC del tester».

A proposito, i requisiti sulle operazioni di unique_ptr (come correttezza di reset() e i post-condizioni degli assignment tramite move) sono formalizzati nella libreria standard con molta cura — fino a note separate e correzioni di formulazioni.

2. Passaggio per valore: la funzione prende la ownership

Quando vedete un parametro per valore di tipo std::unique_ptr<T>, questo quasi sempre significa: la funzione diventa proprietaria della risorsa. Cioè dopo la chiamata il chiamante perderà la proprietà (ha «dato le chiavi dell'appartamento»), e dentro la funzione la risorsa verrà distrutta automaticamente all'uscita dall'ambito — a meno che non la passiate oltre.

È un contratto potente e molto onesto: avete scritto nella firma «io prendo la proprietà». E un vantaggio piacevole: non potete dimenticare che la ownership è stata trasferita, perché il compilatore vi costringerà a scrivere std::move(...) nel punto di chiamata.

Mini-esempio: «mangiare l'oggetto» (consume)

Nel nostro mini-app didattico terremo una semplice entità Note (nota), e anche una «nota attiva» di cui l'applicazione è proprietaria.

#include <iostream>
#include <memory>
#include <string>
#include <utility>

struct Note {
    std::string title;
};

void consume_note(std::unique_ptr<Note> note) {
    if (note) {
        std::cout << "Consumed: " << note->title << '\n'; // Consumed: Buy milk
    }
} // note verrà distrutto qui automaticamente

int main() {
    auto n = std::make_unique<Note>(Note{"Buy milk"});
    consume_note(std::move(n));

    std::cout << std::boolalpha << (n == nullptr) << '\n'; // true
}

Notate il «rito» std::move(n). Sembra una «parola magica», ma il senso è molto umano: «sto cedendo questa ownership». Senza std::move il compilatore non vi lascerà passare — e questo è ottimo.

Quando è corretto

Passare unique_ptr per valore è comodo quando la funzione deve davvero diventare proprietaria, per esempio: salvare l'oggetto da qualche parte, metterlo in coda, aggiungerlo a un contenitore, «accettarlo in custodia».

Immaginate di avere uno storage di note std::vector<std::unique_ptr<Note>>. Allora una funzione «aggiungi nota al database» ha senso prendere la ownership.

#include <memory>
#include <string>
#include <utility>
#include <vector>

struct Note {
    std::string title;
};

void add_note(std::vector<std::unique_ptr<Note>>& notes,
              std::unique_ptr<Note> note) {
    notes.push_back(std::move(note));
}

int main() {
    std::vector<std::unique_ptr<Note>> notes;

    auto n = std::make_unique<Note>(Note{"Read C++ book"});
    add_note(notes, std::move(n));
}

Qui il bel punto è: add_note prende la ownership, e da quel momento il contenitore è il proprietario dell'oggetto.

Piccola ma importante osservazione sulla leggibilità

Se una funzione accetta std::unique_ptr<T> per valore, voi come lettori del codice dovreste aspettarvi che:

  • l'oggetto possa essere distrutto dentro la funzione;
  • l'oggetto possa essere passato oltre (un altro std::move);
  • il chiamante dovrebbe considerare che dopo la chiamata «non lo ha più».

Quindi è come lasciare la giacca al guardaroba: vi danno un ticket, ma la giacca non è più con voi.

3. Passaggio per reference: la funzione gestisce il proprietario

A volte la funzione non deve diventare proprietaria «per sempre», ma ha bisogno di modificare lo stato del proprietario nel codice chiamante. Per esempio, sostituire la nota attiva con una nuova, chiudere la nota (renderla vuota), o «inizializzare se è vuota».

A questo scopo il parametro si fa reference su unique_ptr: std::unique_ptr<T>&.

È importante cogliere il senso: la reference qui non è su T, ma sull'oggetto-proprietario (unique_ptr). Quindi la funzione non «prende in braccio la nota», ma «interviene nel numero del guardaroba e cambia cosa c'è dentro».

Mini-esempio: impostare la nota attiva

#include <memory>
#include <string>
#include <utility>

struct Note {
    std::string title;
};

void set_active(std::unique_ptr<Note>& active, std::unique_ptr<Note> next) {
    active = std::move(next); // la ownership si sposta in active
}

int main() {
    std::unique_ptr<Note> active;

    auto n = std::make_unique<Note>(Note{"Plan weekend"});
    set_active(active, std::move(n));
}

Qui l'equilibrio è importante: il parametro next per valore significa «ti do la nota», mentre active per reference significa «modifica il mio proprietario».

Quando è corretto

Passare std::unique_ptr<T>& è appropriato quando la funzione deve:

  • sostituire l'oggetto del proprietario;
  • «riempire» se era vuoto;
  • trasferire la proprietà oltre da quel proprietario (per esempio estrarre l'oggetto attivo e inserirlo nello storage — ma questo si avvicina molto a un'operazione di «estrazione» e richiede attenzione).

Più avanti (in una lezione correlata) esaminerete le operazioni get()/reset()/release()/swap(). Lì sarà ancora più chiaro quanto sia facile «tagliarsi» al confine della proprietà se si tratta unique_ptr come un puntatore normale.

4. Osservare, ma non possedere: const T& e const T*

Quando scrivete una funzione «stampa la nota», «calcola la lunghezza del titolo», «controlla che il titolo non sia vuoto» — la funzione non dovrebbe possedere l'oggetto. Ha solo bisogno di usare l'oggetto.

È qui che iniziano gli errori tipici dei principianti: portano nei parametri std::unique_ptr<T>, perché «ormai usiamo sempre unique_ptr». Di conseguenza la funzione richiede improvvisamente std::move e «prende la nota», mentre voi dovevate solo leggere i campi. È come chiedere la penna a un amico «per un secondo» e poi andarvene col suo regalo, perché formalmente ve l'ha ceduta.

Modo corretto per «solo leggere»: const Note&

Se l'oggetto deve esistere, usate const T&.

#include <iostream>
#include <string>

struct Note {
    std::string title;
};

void print_note(const Note& note) {
    std::cout << note.title << '\n';
}

int main() {
    Note n{"Hello"};
    print_note(n); // Hello
}

La reference dice: «l'oggetto esiste», const dice: «non lo modifico».

Quando l'oggetto può essere assente: const Note*

Se la nota può non essere «selezionata» (cioè unique_ptr è vuoto), è più comodo esprimerlo con un puntatore const T*:

#include <iostream>
#include <string>

struct Note {
    std::string title;
};

void print_note_ptr(const Note* note) {
    if (note) {
        std::cout << note->title << '\n';
    } else {
        std::cout << "(no active note)\n"; // (no active note)
    }
}

Qui il contratto è ancora più onesto: nullptr significa «nessun oggetto».

Come chiamare, se abbiamo un unique_ptr

Se avete std::unique_ptr<Note> active;, potete passare un osservatore senza toccare la proprietà.

Nel minimo esempio questo appare così (l'operazione get() la vedrete in dettaglio più avanti; per ora consideratela come «dare l'indirizzo»):

#include <memory>

void print_note_ptr(const Note* note);

int main() {
    std::unique_ptr<Note> active;
    print_note_ptr(active.get()); // oppure indirizzo, oppure nullptr
}

Idea principale: l'osservazione è separata dalla proprietà. unique_ptr è per la proprietà. T*/const T* e T&/const T& sono per l'utilizzo.

Caso raro ma utile: const std::unique_ptr<T>&

A volte la funzione deve «guardare» non solo l'oggetto ma anche lo stato del proprietario: se è vuoto o no, quale deleter ha (più avanti), o semplicemente non si vuole esporre all'esterno T*/T& (per esempio scrivete un'utility di diagnostica del proprietario).

Allora potete accettare const std::unique_ptr<T>&. Questo significa: «non prendo la proprietà, non modifico il proprietario, guardo solamente».

#include <iostream>
#include <memory>

struct Note {
    int id{};
};

void debug_owner(const std::unique_ptr<Note>& p) {
    std::cout << std::boolalpha << static_cast<bool>(p) << '\n';
}

int main() {
    std::unique_ptr<Note> p;
    debug_owner(p); // false
}

Ma tenete a mente una regola pratica: se volete lavorare con l'oggetto stesso, spesso è più leggibile e semplice usare const T& o const T*. const unique_ptr<T>& è già «io voglio parlare proprio della proprietà».

5. Criteri di scelta: cosa passare nei parametri

Quando siete davanti alla scelta della firma, è utile farvi una domanda: cosa succederà con la proprietà prima e dopo la chiamata? Se sapete rispondere a parole semplici, la firma di solito emerge quasi automaticamente.

Di seguito — una tabella-guida. Potete tenerla a mente come appunto finché non diventa abitudine.

Scopo della funzione (per significato) Firma del parametro Cosa significa «in parole umane»
La funzione prende la proprietà e decide la sorte della risorsa
std::unique_ptr<T>
«Dammi l'oggetto, non lo avrai più»
La funzione può sostituire l'oggetto del proprietario (gestisce il proprietario)
std::unique_ptr<T>&
«Dammi accesso al tuo proprietario, posso cambiare cosa possiede»
La funzione semplicemente legge l'oggetto, l'oggetto deve esistere
const T&
«Guardo ma non modifico; l'oggetto non può mancare»
La funzione legge l'oggetto, ma l'oggetto potrebbe non esserci
const T*
«Guardo se c'è; altrimenti sopravvivo»
La funzione osserva proprio il proprietario (controllare vuoto, ecc.)
const std::unique_ptr<T>&
«Leggo lo stato del proprietario, ma non tocco la proprietà»

Notate che in questa tabella «per valore o per reference» è solo una parte della decisione. Spesso la risposta corretta è semplicemente «nessuno: unique_ptr qui non serve, prendi const T&».

6. Mini-codice pratico: nota attiva e archivio

Ora assembleremo un piccolo pezzo di codice che mostra i tre ruoli: creare una nota, impostare l'attiva, stampare l'attiva, aggiungere all'archivio. Non è una applicazione completa, ma è un buon «scheletro» facilmente estendibile.

Modello e funzioni

#include <iostream>
#include <memory>
#include <string>
#include <utility>
#include <vector>

struct Note {
    std::string title;
};

std::unique_ptr<Note> make_note(std::string title) {
    return std::make_unique<Note>(Note{std::move(title)});
}

void set_active(std::unique_ptr<Note>& active, std::unique_ptr<Note> next) {
    active = std::move(next);
}

void print_note_ptr(const Note* note) {
    if (note) {
        std::cout << "Active: " << note->title << '\n';
    } else {
        std::cout << "Active: (none)\n";
    }
}

void archive(std::vector<std::unique_ptr<Note>>& storage, std::unique_ptr<Note> note) {
    if (note) {
        storage.push_back(std::move(note));
    }
}

int main() {
    std::vector<std::unique_ptr<Note>> archive_storage;
    std::unique_ptr<Note> active;

    print_note_ptr(active.get());                 // Active: (none)

    auto n = make_note("Buy milk");
    set_active(active, std::move(n));

    print_note_ptr(active.get());                 // Active: Buy milk

    archive(archive_storage, std::move(active));  // la nota attiva è andata nell'archivio
    print_note_ptr(active.get());                 // Active: (none)
}

Qui tutto il «dramma della proprietà» è visibile senza un singolo commento sulla ownership — perché i tipi e std::move la rendono esplicita. Ed è buon stile: meno magia nascosta, più accordi chiari.

7. Errori tipici

Errore #1: accettare std::unique_ptr<T> per valore quando volevate solo «leggere».
È classico: scrivete void print(std::unique_ptr<Note> n), e poi vi stupite perché serve std::move, e perché dopo la stampa la nota è scomparsa. Non è una «peculiarità del C++», è il vostro contratto: parametro per valore per unique_ptr significa «cedi la proprietà». Per stampare quasi sempre è meglio const Note& (se l'oggetto deve esistere) o const Note* (se può mancare).

Errore #2: cercare di chiamare una funzione che «prende» senza std::move.
Il principiante vede consume(p) e pensa: «sto solo passando la variabile». Ma unique_ptr non è copiabile, e passare per valore richiede copia o move. Se la funzione accetta std::unique_ptr<T>, la chiamata deve essere consume(std::move(p)). Se non vi piace std::move — allora non volete cedere la proprietà, e dovreste cambiare la firma.

Errore #3: usare l'oggetto dopo std::move come se nulla fosse successo.
Dopo set_active(active, std::move(n)) la variabile n di solito diventa vuota. È valida, la potete riassegnare o confrontare con nullptr, ma non potete dereferenziare senza controllare. È una causa comune di «sembrava funzionare, poi è crash».

Errore #4: cercare di «aiutare» e creare manualmente una copia di unique_ptr tramite puntatore raw.
A volte lo studente fa il terribile: prende T* raw = p.get(); (o peggio ottiene un T* da qualche parte) e poi crea un secondo std::unique_ptr<T> dallo stesso indirizzo. Questo rompe l'idea stessa di «proprietario unico»: due proprietari cercheranno di liberare la stessa risorsa. Se vi serve ownership condivisa — è uno strumento diverso e un argomento a parte; qui la regola è semplice: una risorsa — un unique_ptr.

Errore #5: passare std::unique_ptr<T>& quando in realtà volevate «prendere la proprietà».
Viceversa: volete che la funzione prenda l'oggetto «con la chiusura», ma scrivete foo(std::unique_ptr<T>&). Allora il chiamante non è obbligato a fare std::move, e dalla chiamata non si capisce che la proprietà andrà da qualche parte. Alla fine il codice è meno onesto. Se la funzione deve diventare proprietaria, accettate std::unique_ptr<T> per valore: questo costringerà il chiamante a confermare esplicitamente il trasferimento di ownership.

1
Compito
C++ SELF, livello 43, lezione 0
Bloccato
Pacchetto per il corriere
Pacchetto per il corriere
1
Compito
C++ SELF, livello 43, lezione 0
Bloccato
Sessione attiva
Sessione attiva
1
Compito
C++ SELF, livello 43, lezione 0
Bloccato
Nota senza trasferimento
Nota senza trasferimento
1
Compito
C++ SELF, livello 43, lezione 0
Bloccato
Queue di job
Queue di job
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION