Puntatori C++: perché ne abbiamo bisogno, quando li usiamo, come differiscono dall’accesso all’oggetto stesso
Anche se la maggior parte dei programmatori capisce qual è la differenza tra oggetti e puntatori ad essi, a volte non è completamente chiaro quale dei modi di accedere all’oggetto scegliere. Abbiamo cercato di rispondere a questa domanda qui sotto.
Domanda
Vengo da un background Java e ho iniziato a lavorare con gli oggetti in C++. Ma una cosa che mi è venuta in mente è che la gente spesso usa i puntatori agli oggetti piuttosto che gli oggetti stessi, per esempio questa dichiarazione:
Object *myObject = new Object;
invece di:
Object myObject;
O invece di usare una funzione, diciamo testFunc(), come questa:
myObject.testFunc();
dobbiamo scrivere:
myObject->testFunc();
Ma non riesco a capire perché dovremmo fare così. Presumo che abbia a che fare con l’efficienza e la velocità, dato che abbiamo accesso diretto all’indirizzo di memoria. Ho ragione?
Risposta
A proposito, dovremmo notare che i puntatori in Java non sono usati esplicitamente, per esempio un programmatore non può accedere ad un oggetto nel codice attraverso un puntatore ad esso. Tuttavia, in Java tutti i tipi, eccetto base, sono referenziati: l’accesso ad essi avviene tramite il collegamento, anche se non è possibile passare esplicitamente il parametro tramite collegamento. Oltre a questo, nuovo in C++ e Java o C# sono cose diverse.
Per dare un’idea dei puntatori in C++, daremo due frammenti di codice simili:
Java:
Object object1 = new Object(); //A new object is allocated by JavaObject object2 = new Object(); //Another new object is allocated by Javaobject1 = object2; //object1 now points to the object originally allocated for object2//The object originally allocated for object1 is now "dead" – //nothing points to it, so it//will be reclaimed by the Garbage Collector.//If either object1 or object2 is changed, //the change will be reflected to the other
L’equivalente più vicino a questo, è:
C++:
Object * object1 = new Object(); //A new object is allocated on the heapObject * object2 = new Object(); //Another new object is allocated on the heapdelete object1;//Since C++ does not have a garbage collector, //if we don't do that, the next line would //cause a "memory leak", i.e. a piece of claimed memory that //the app cannot use //and that we have no way to reclaim...object1 = object2; //Same as Java, object1 points to object2.
Vediamo il modo alternativo del C++:
Object object1; //A new object is allocated on the STACKObject object2; //Another new object is allocated on the STACKobject1 = object2;//!!!! This is different! //The CONTENTS of object2 are COPIED onto object1,//using the "copy assignment operator", the definition of operator =.//But, the two objects are still different. //Change one, the other remains unchanged.//Also, the objects get automatically destroyed //once the function returns...
Abbiamo un guadagno in velocità, accedendo direttamente alla memoria?
In realtà, no. I puntatori sono di solito usati per l’accesso all’heap mentre gli oggetti si trovano nello stack – questa è una struttura più semplice e veloce. Se sei un principiante, abbiamo per te del materiale in cui diciamo in dettaglio cos’è uno stack e un heap.
In senso stretto, questa domanda combina due questioni diverse. Primo: quando si usa l’allocazione dinamica della memoria? Secondo: quando è meglio usare i puntatori? Certo, non faremo a meno delle parole comuni che bisogna sempre scegliere lo strumento più appropriato per il lavoro. Quasi sempre c’è una realizzazione migliore che usare l’allocazione dinamica manuale (dynamic allocation) e/o i puntatori grezzi.
È molto spiacevole che si veda così spesso l’allocazione dinamica. Questo dimostra solo quanti cattivi programmatori C++ ci sono.
In un certo senso, avete due domande riunite in una. La prima è quando dovremmo usare l’allocazione dinamica (usando new)? La seconda è quando dovremmo usare i puntatori?
L’importante messaggio da portare a casa è che dovreste sempre usare lo strumento appropriato per il lavoro. In quasi tutte le situazioni, c’è qualcosa di più appropriato e più sicuro che eseguire un’allocazione dinamica manuale e/o usare puntatori grezzi.
Allocazione dinamica
Nella tua domanda, hai dimostrato due modi di creare un oggetto. La differenza principale è la durata di memorizzazione dell’oggetto. Quando si fa Object myObject; all’interno di un blocco, l’oggetto viene creato con durata di memorizzazione automatica, il che significa che verrà distrutto automaticamente quando esce dallo scope. Quando fate new Object(), l’oggetto ha una durata di memorizzazione dinamica, il che significa che rimane in vita finché non lo cancellate esplicitamente. Dovreste usare la durata di memorizzazione dinamica solo quando ne avete bisogno. Cioè, dovreste sempre preferire la creazione di oggetti con durata di memorizzazione automatica quando potete.
Le due principali situazioni in cui potreste richiedere un’allocazione dinamica:
- Avete bisogno che l’oggetto sopravviva allo scopo corrente – quello specifico oggetto in quella specifica posizione di memoria, non una sua copia. Se ti sta bene copiare/spostare l’oggetto (la maggior parte delle volte dovrebbe essere così), dovresti preferire un oggetto automatico.
- Hai bisogno di allocare molta memoria, che può facilmente riempire lo stack. Sarebbe bello se non dovessimo preoccuparci di questo (la maggior parte delle volte non dovreste farlo), dato che è davvero al di fuori dell’ambito del C++, ma purtroppo dobbiamo fare i conti con la realtà dei sistemi per cui stiamo sviluppando.
- Non sapete esattamente la dimensione dell’array, che dovrete usare. Come sai, in C++ la dimensione degli array è fissa. Questo può causare problemi, per esempio, durante la lettura dell’input dell’utente. Il puntatore definisce solo quella sezione di memoria, dove l’inizio di una matrice sarà scritto, non limitando la sua dimensione.
Se un uso di allocazione dinamica è necessario, si dovrebbe incapsulare utilizzando puntatore intelligente o di un altro tipo che supporta l’idioma “L’acquisizione della risorsa è l’inizializzazione” (contenitori standard lo supportano – è un idioma, in conformità con il quale la risorsa: un blocco di memoria, file, connessione di rete, ecc – sono inizializzati mentre si ottiene nel costruttore, e poi con attenzione vengono distrutti dal distruttore). Per esempio, i puntatori intelligenti sono std::unique_ptr e std::shared_ptr
Pointers
Tuttavia, ci sono altri usi più generali per i puntatori grezzi oltre all’allocazione dinamica, ma la maggior parte ha alternative che dovreste preferire. Come prima, preferite sempre le alternative a meno che non abbiate davvero bisogno dei puntatori.
- Avete bisogno della semantica di riferimento. A volte volete passare un oggetto usando un puntatore (indipendentemente da come è stato allocato) perché volete che la funzione a cui lo state passando abbia accesso a quell’oggetto specifico (non una sua copia). Tuttavia, nella maggior parte delle situazioni, dovreste preferire i tipi di riferimento ai puntatori, perché questo è specificamente ciò per cui sono progettati. Notate che non si tratta necessariamente di estendere la vita dell’oggetto oltre l’ambito corrente, come nella situazione 1 sopra. Come prima, se vi va bene passare una copia dell’oggetto, non avete bisogno della semantica di riferimento.
- Avete bisogno del polimorfismo. Puoi chiamare funzioni in modo polimorfico (cioè secondo il tipo dinamico di un oggetto) solo attraverso un puntatore o un riferimento all’oggetto. Se questo è il comportamento di cui hai bisogno, allora devi usare puntatori o riferimenti. Anche in questo caso, i riferimenti dovrebbero essere preferiti.
- Si vuole rappresentare che un oggetto è opzionale permettendo di passare un nullptr quando l’oggetto viene omesso. Se è un argomento, si dovrebbe preferire l’uso di argomenti predefiniti o sovraccarichi di funzione. Altrimenti, si dovrebbe preferire l’uso di un tipo che incapsuli questo comportamento, come std::optional (introdotto in C++17 – con gli standard C++ precedenti, usare boost::optional).
- Si vuole disaccoppiare le unità di compilazione per migliorare il tempo di compilazione. La proprietà utile di un puntatore è che si richiede solo una dichiarazione in avanti del tipo puntato (per usare effettivamente l’oggetto, è necessaria una definizione). Questo vi permette di disaccoppiare parti del vostro processo di compilazione, il che può migliorare significativamente il tempo di compilazione. Vedi l’idioma Pimpl.
- Devi interfacciarti con una libreria C o una libreria in stile C. A questo punto, sei costretto a usare puntatori grezzi. La cosa migliore che potete fare è assicurarvi di liberare i vostri puntatori grezzi solo all’ultimo momento possibile. Potete ottenere un puntatore grezzo da un puntatore intelligente, per esempio, usando la sua funzione membro get. Se una libreria esegue per voi un’allocazione che si aspetta che voi deallocate tramite un handle, spesso potete avvolgere l’handle in uno smart pointer con un deleter personalizzato che deallocherà l’oggetto in modo appropriato.