C ++ in modo succinto puntatori, riferimenti e correttezza costante

Introduzione ai puntatori

Un puntatore non è altro che una variabile che contiene un indirizzo di memoria. Se usato correttamente, un puntatore contiene un indirizzo di memoria valido che contiene un oggetto, che è compatibile con il tipo del puntatore. Come i riferimenti in C #, tutti i puntatori in un particolare ambiente di esecuzione hanno le stesse dimensioni, indipendentemente dal tipo di dati a cui punta il puntatore. Ad esempio, quando un programma viene compilato ed eseguito su un sistema operativo a 32 bit, un puntatore sarà tipicamente di 4 byte (32 bit).

I puntatori possono puntare a qualsiasi indirizzo di memoria. Puoi, e spesso lo faranno, avere dei riferimenti a oggetti che sono in pila. È inoltre possibile avere puntatori a oggetti statici, a thread di oggetti locali e, ovviamente, a oggetti dinamici (cioè allocati su heap). Quando i programmatori pensano solo a una familiarità passante con i puntatori, di solito si trovano nel contesto di oggetti dinamici.

A causa di potenziali perdite, non si dovrebbe mai allocare memoria dinamica al di fuori di un puntatore intelligente. La libreria standard C ++ fornisce due indicatori intelligenti che è necessario prendere in considerazione: std :: shared_ptr e std :: unique_ptr.

Inserendo oggetti di durata dinamica all'interno di uno di questi, si garantisce che quando il std :: unique_ptr, o l'ultimo std :: shared_ptr che contiene un puntatore a quella memoria che va fuori dal campo di applicazione, la memoria verrà correttamente liberata con la versione corretta di delete (cancella o cancella []) in modo che non perda. Questo è lo schema RAII del capitolo precedente in azione.

Solo due cose possono accadere quando si esegue RAII a destra con puntatori intelligenti: l'allocazione ha esito positivo e pertanto la memoria verrà liberata correttamente quando il puntatore intelligente esce dall'ambito o l'allocazione fallisce, nel qual caso non è stata allocata memoria e quindi non perdita. In pratica, l'ultima situazione dovrebbe essere piuttosto rara sui moderni PC e server a causa della loro ampia memoria e della loro fornitura di memoria virtuale.

Se non usi puntatori intelligenti, stai solo chiedendo una perdita di memoria. Qualsiasi eccezione tra l'allocazione della memoria con nuovo o nuovo [] e la liberazione della memoria con cancellazione o eliminazione [] probabilmente causerà una perdita di memoria. Se non si presta attenzione, è possibile utilizzare accidentalmente un puntatore già eliminato, ma non impostato uguale a nullptr. Dovresti quindi accedere a qualche posizione casuale nella memoria e trattarla come se fosse un puntatore valido.

La cosa migliore che potrebbe accadere in quel caso è che il tuo programma si arresti. In caso contrario, stai corrompendo i dati in modi strani e sconosciuti e magari salvando tali corruzioni in un database o spingendoli attraverso il web. Potresti aprire la porta anche a problemi di sicurezza. Quindi usa puntatori intelligenti e lascia che il linguaggio gestisca i problemi di gestione della memoria per te.


Const Pointer

Un puntatore const assume la forma SomeClass * const someClass2 = & someClass1;. In altre parole, il * viene prima di const. Il risultato è che il puntatore stesso non può puntare a nient'altro, ma i dati a cui punta il puntatore rimangono mutabili. Probabilmente non è molto utile nella maggior parte delle situazioni.

Puntatore a Const

Un puntatore a const assume la forma const SomeClass * someClass2 = & someClass1;. In questo caso il * viene dopo const. Il risultato è che il puntatore può puntare ad altre cose, ma non è possibile modificare i dati a cui punta. Questo è un modo comune per dichiarare parametri che si desidera semplicemente ispezionare senza modificare i propri dati.

Const Pointer a Const

Un puntatore const su const assume la forma const SomeClass * const someClass2 = & someClass1;. Qui, * è inserito tra due parole chiave const. Il risultato è che il puntatore non può puntare a nient'altro e non è possibile modificare i dati a cui punta.

Const-Correctness e Const Member Functions

Const-correctness si riferisce all'uso della parola chiave const per decorare sia i parametri che le funzioni in modo che la presenza o l'assenza della parola chiave const trasmetta correttamente qualsiasi potenziale effetto collaterale. È possibile contrassegnare una funzione membro const inserendo la parola chiave const dopo la dichiarazione dei parametri della funzione.

Per esempio, int GetSomeInt (void) const; dichiara una funzione membro const, una funzione membro che non modifica i dati dell'oggetto a cui appartiene. Il compilatore farà valere questa garanzia. Impone inoltre la garanzia che quando si passa un oggetto in una funzione che lo assume come const, tale funzione non può chiamare alcuna funzione membro non const di quell'oggetto.

Progettare il tuo programma per aderire alla correttezza è più facile quando inizi a farlo dall'inizio. Quando si aderisce alla cost-correttezza, diventa più facile usare il multithreading, poiché si sa esattamente quali funzioni membro hanno effetti collaterali. È anche più facile rintracciare bug relativi a stati di dati non validi. Gli altri che collaborano con te a un progetto saranno anche a conoscenza di potenziali modifiche ai dati della classe quando chiamano determinate funzioni membro.


Il *, &, e -> operatori

Quando si lavora con i puntatori, compresi i puntatori intelligenti, sono interessati tre operatori: *, &, e ->.

L'operatore di riferimento indiretto, *, de-fa riferimento a un puntatore, nel senso che si lavora con i dati puntati, anziché il puntatore stesso. Per i prossimi paragrafi, supponiamo che p_someInt sia un puntatore valido a un intero senza qualifiche const.

La dichiarazione p_someInt = 5000000; non assegnerebbe il valore 5000000 al numero intero a cui è rivolto. Invece, imposterà il puntatore a puntare all'indirizzo di memoria 5000000, 0X004C4B40 su un sistema a 32 bit. Che cosa è all'indirizzo di memoria 0X004C4B40? Chissà? Potrebbe essere il tuo numero intero, ma è probabile che sia qualcos'altro. Se sei fortunato, è un indirizzo non valido. La prossima volta che tenti di usare p_someInt correttamente, il programma si bloccherà. Se è un indirizzo dati valido, allora probabilmente corromperà i dati.

La dichiarazione * p_someInt = 5000000; assegnerà il valore 5000000 al numero intero puntato da p_someInt. Questo è l'operatore indiretto in azione; prende p_someInt e lo sostituisce con un valore L che rappresenta i dati all'indirizzo indicato (discuteremo presto dei valori L).

L'indirizzo-dell'operatore, &, recupera l'indirizzo di una variabile o di una funzione. Ciò consente di creare un puntatore a un oggetto locale, che è possibile passare a una funzione che desidera un puntatore. Non hai nemmeno bisogno di creare un puntatore locale per farlo; puoi semplicemente usare la tua variabile locale con l'operatore address-of di fronte come argomento, e tutto funzionerà bene.

I puntatori alle funzioni sono simili a delegare le istanze in C #. Data questa dichiarazione di funzione: double GetValue (int idx); questo sarebbe il puntatore di funzione giusto: double (* SomeFunctionPtr) (int);.

Se la tua funzione ha restituito un puntatore, dì in questo modo: int * GetIntPtr (void); allora questo sarebbe il puntatore di funzione giusto: int * (* SomeIntPtrDelegate) (void);. Non lasciare che i doppi asterischi ti infastidiscano; ricorda solo il primo insieme di parentesi attorno al * e il nome del puntatore alla funzione, in modo che il compilatore interpreti correttamente questo come un puntatore a funzione piuttosto che una dichiarazione di funzione.

L'operatore di accesso> -> è quello che usi per accedere ai membri della classe quando hai un puntatore a un'istanza di classe. Funziona come una combinazione dell'operatore indiretto e il. operatore di accesso membro. Così p_someClassInstance-> SetValue (10); e (* P_someClassInstance) .SetValue (10); entrambi fanno la stessa cosa.

Valori L e valori R

Non sarebbe C ++ se non parlassimo dei valori L e dei valori R almeno brevemente. I valori L sono così chiamati perché tradizionalmente appaiono sul lato sinistro di un segno di uguale. In altre parole, sono valori che possono essere assegnati a quelli che sopravviveranno alla valutazione dell'espressione corrente. Il tipo più comune di valore L è una variabile, ma include anche il risultato della chiamata a una funzione che restituisce un riferimento al valore L.

I valori R appaiono tradizionalmente sul lato destro dell'equazione o, forse più precisamente, sono valori che non possono apparire a sinistra. Sono cose come le costanti o il risultato della valutazione di un'equazione. Ad esempio, a + b dove a e b potrebbero essere valori L, ma il risultato di sommarli è un valore R o il valore di ritorno di una funzione che restituisce qualcosa di diverso da void o un riferimento di valore L.

Riferimenti

I riferimenti agiscono proprio come le variabili non puntatore. Una volta inizializzato un riferimento, non può fare riferimento a un altro oggetto. Devi anche inizializzare un riferimento dove lo dichiari. Se le tue funzioni prendono riferimenti piuttosto che oggetti, non dovrai sostenere il costo di una costruzione di copia. Poiché il riferimento si riferisce all'oggetto, le modifiche ad esso sono modifiche all'oggetto stesso.

Proprio come i puntatori, puoi anche avere un riferimento const. A meno che non sia necessario modificare l'oggetto, è necessario utilizzare i riferimenti const poiché forniscono controlli del compilatore per garantire che non si stia mutando l'oggetto quando si pensa di non essere.

Esistono due tipi di riferimenti: riferimenti a valori L e riferimenti a valori R. Un riferimento al valore L è contrassegnato da un & aggiunto al nome del tipo (ad es. SomeClass &), mentre un riferimento al valore R è contrassegnato da un & & aggiunto al nome del tipo (ad es. SomeClass &&). Per la maggior parte, agiscono allo stesso modo; la differenza principale è che il riferimento al valore R è estremamente importante per spostare la semantica.


Puntatore e campione di riferimento

Il seguente esempio mostra l'uso di puntatori e riferimenti con le spiegazioni nei commenti.

Esempio: PointerSample \ PointerSample.cpp

#includere  //// Vedere il commento al primo utilizzo di assert () in _pmain di seguito. // # define NDEBUG 1 #include  #include "... /pchar.h" usando namespace std; void SetValueToZero (int & value) value = 0;  void SetValueToZero (int * value) * value = 0;  int _pmain (int / * argc * /, _pchar * / * argv * / []) int value = 0; const int intArrCount = 20; // Crea un puntatore a int. int * p_intArr = new int [intArrCount]; // Crea un puntatore const su int. int * const cp_intArr = p_intArr; // Queste due affermazioni vanno bene poiché possiamo modificare i dati a cui punta un puntatore // const. // Imposta tutti gli elementi su 5. uninitialized_fill_n (cp_intArr, intArrCount, 5); // Imposta il primo elemento su zero. * cp_intArr = 0; //// Questa istruzione è illegale perché non possiamo modificare a cosa punta un puntatore const ////. // cp_intArr = nullptr; // Crea un puntatore a const int. const int * pc_intArr = nullptr; // Questo va bene perché possiamo modificare ciò che un puntatore a const fa // a. pc_intArr = p_intArr; // Assicurati di "usare" pc_intArr. valore = * pc_intArr; //// Questa istruzione è illegale poiché non possiamo modificare i dati a cui punta un puntatore //// a const. // * pc_intArr = 10; const int * const cpc_intArr = p_intArr; //// Queste due istruzioni sono illegali perché non possiamo modificare //// a cosa punta un puntatore const su const oi dati a cui //// punta. // cpc_intArr = p_intArr; // * cpc_intArr = 20; // Assicurati di "usare" cpc_intArr. value = * cpc_intArr; * p_intArr = 6; SetValueToZero (* p_intArr); // A partire dal , questa macro visualizzerà un messaggio diagnostico se l'espressione // tra parentesi restituisce valori diversi da zero. // A differenza della macro _ASSERTE, questa verrà eseguita durante le build di rilascio. Per // disabilitarlo, definire NDEBUG prima di includere il  intestazione. assert (* p_intArr == 0); * p_intArr = 9; int & r_first = * p_intArr; SetValueToZero (r_first); assert (* p_intArr == 0); const int & cr_first = * p_intArr; //// Questa istruzione è illegale perché cr_first è un riferimento const, //// ma SetValueToZero non prende un riferimento const, solo un riferimento // non-const, che ha senso considerando che vuole //// modificare il valore. // SetValueToZero (cr_first); valore = cr_first; // Possiamo inizializzare un puntatore usando l'operatore address-of. // Basta essere cauti perché le variabili locali non statiche diventano // non valide quando si esce dal loro ambito, quindi tutti i puntatori a loro // diventano non validi. int * p_firstElement = &r_first; * p_firstElement = 10; SetValueToZero (* p_firstElement); assert (* p_firstElement == 0); // Questo chiamerà l'overload SetValueToZero (int *) perché // stiamo usando l'operatore address-of per trasformare il riferimento in // un puntatore. SetValueToZero (& r_first); * p_intArr = 3; SetValueToZero (& (* p_intArr)); assert (* p_firstElement == 0); // Crea un puntatore a funzione. Si noti come dobbiamo mettere il // nome della variabile tra parentesi con un * prima di esso. void (* FunctionPtrToSVTZ) (int &) = nullptr; // Imposta il puntatore della funzione in modo che punti su SetValueToZero. Prende // il sovraccarico corretto automaticamente. FunctionPtrToSVTZ = & SetValueToZero; * p_intArr = 20; // Chiama la funzione puntata da FunctionPtrToSVTZ, ad esempio // SetValueToZero (int &). FunctionPtrToSVTZ (* p_intArr); assert (* p_intArr == 0); * p_intArr = 50; // Possiamo anche chiamare un puntatore a funzione come questo. Questo è // più vicino a ciò che sta realmente accadendo dietro le quinte; // FunctionPtrToSVTZ viene de-referenziato con il risultato // che è la funzione a cui è indirizzato, che quindi // chiamerà utilizzando il valore (s) specificato nel secondo insieme di // parentesi, ad esempio * p_intArr qui. (* FunctionPtrToSVTZ) (* p_intArr); assert (* p_intArr == 0); // Assicurati di ottenere il valore impostato su 0 in modo che possiamo "usarlo". * p_intArr = 0; value = * p_intArr; // Elimina p_intArray usando l'operatore delete [] poiché è un // p_intArray dinamico. elimina [] p_intArr; p_intArr = nullptr; valore di ritorno; 

Volatile

Cito volatile solo per avvertire di non usarlo. Come const, una variabile può essere dichiarata volatile. Puoi anche avere un const volatile; Le due cose non si escludono a vicenda.

Ecco la cosa su volatile: probabilmente non significa quello che pensi che significhi. Ad esempio, non è buono per la programmazione multithread. Il caso di utilizzo effettivo per volatile è estremamente ridotto. Le probabilità sono, se metti il ​​qualificatore volatile su una variabile, stai facendo qualcosa di terribilmente sbagliato.

Eric Lippert, membro del team linguistico C # di Microsoft, ha descritto l'uso di volatile come "Un segno che stai facendo qualcosa di completamente pazzo: stai tentando di leggere e scrivere lo stesso valore su due thread diversi senza mettere un lucchetto sul posto. "Ha ragione, e la sua argomentazione si fonda perfettamente in C++.

L'uso di volatile dovrebbe essere accolto con più scetticismo rispetto all'uso di goto. Dico questo perché posso pensare ad almeno un uso valido valido di goto: scoppiare da un loop profondamente annidato costruisca al completamento di una condizione non eccezionale. volatile, al contrario, è davvero utile solo se si sta scrivendo un driver di periferica o si scrive codice per qualche tipo di chip ROM. A questo punto, dovresti conoscere a fondo lo stesso linguaggio di programmazione ISO / IEC C ++, le specifiche hardware per l'ambiente di esecuzione in cui verrà eseguito il tuo codice e probabilmente anche lo standard ISO / IEC C Language.

Nota: Dovresti anche avere familiarità con il linguaggio assembly per l'hardware di destinazione, così puoi guardare il codice che viene generato e assicurarti che il compilatore stia generando il codice corretto (PDF) per il tuo uso di volatile.

Ho ignorato l'esistenza della parola chiave volatile e continuerò a farlo per il resto di questo libro. Questo è perfettamente sicuro, dal momento che:

  • È una funzionalità linguistica che non entra in gioco a meno che tu non la usi effettivamente.
  • Il suo utilizzo può essere tranquillamente evitato praticamente da tutti.

Un'ultima nota su volatile: l'unico effetto che è molto probabile produrre è il codice più lento. C'era una volta, la gente pensava che la volatilità producesse lo stesso risultato dell'atomicità. Non è così. Se correttamente implementato, l'atomicità garantisce che più thread e più processori non possono leggere e scrivere contemporaneamente un blocco di memoria a cui si accede in modo atomico. I meccanismi per questo sono serrature, mutex, semafoni, recinzioni, istruzioni speciali del processore e simili. L'unica cosa volatile è forzare la CPU a recuperare una variabile volatile dalla memoria piuttosto che usare qualsiasi valore che potrebbe aver memorizzato nella cache di un registro o di uno stack. È il recupero della memoria che rallenta tutto.

Conclusione

Puntatori e riferimenti non solo confondono molti sviluppatori, sono molto importanti in un linguaggio come il C ++. È quindi importante prendersi il tempo necessario per cogliere il concetto in modo da non incorrere in problemi lungo la strada. Il prossimo articolo parla di casting in C++.

Questa lezione rappresenta un capitolo di C ++, un eBook gratuito del team di Syncfusion.