Stăpânirea elementelor fundamentale ale pointerilor în C și C++

Ultima actualizare: 12/02/2025
  • Pointerii reprezintă adrese de memorie și permit controlul direct asupra locului și modului în care sunt stocate și accesate datele.
  • Dereferențierea, corectitudinea constantelor și aritmetica pointerilor sunt esențiale pentru utilizarea în siguranță a pointerilor cu tablouri, structuri și memorie dinamică.
  • Pointerii permit transmiterea argumentelor prin referință, construirea de structuri dinamice și implementarea indirecției pe mai multe niveluri, cum ar fi pointer-to-pointer.
  • Verificările nule, alocarea/dealocarea corectă și inițializarea disciplinată sunt cruciale pentru a evita comportamentul nedefinit și blocările.

fundamentele indicatorilor

Pointerii în C și C++ au o reputație legendară: puternici, dificili și capabili să vă blocheze programul într-o clipă dacă sunteți neatenți. Totuși, odată ce înțelegeți cu adevărat ce este un pointer - doar o adresă de memorie - o bună parte din acest mister începe să se estompeze și obțineți acces la unul dintre cele mai versatile instrumente din programarea de nivel scăzut și de sistem.

Acest articol vă prezintă pas cu pas, de la ideea de bază a adreselor de memorie și a pointerilor simpli, trecând prin referințe, tablouri, clase și memorie dinamică, până la cazurile de utilizare pointer-to-pointer și capcanele comune. Scopul este de a face ca lucrul cu pointeri să fie natural, nu ca magia neagră, astfel încât să puteți raționa despre ce se întâmplă cu adevărat în memorie atunci când rulează codul.

Înțelegerea variabilelor și a adreselor de memorie

memorie și variabile

Înainte de a atinge pointerii, aveți nevoie de o imagine mentală clară a modului în care variabilele se află în memorie. Memoria RAM a unui computer este conceptual o matrice lungă de octeți, fiecare octet etichetat cu o adresă numerică unică. Când declarați o variabilă, compilatorul rezervă unul sau mai mulți dintre acești octeți și asociază adresa respectivă cu numele variabilei.

Gândiți-vă la o variabilă ca la o casetă etichetată plasată undeva în memorie: numele este eticheta, adresa este locația fizică de pe raft, iar conținutul este valoarea stocată în interiorul casetei. De exemplu, dacă aveți un int Pe un Arduino UNO tipic, acesta va ocupa 2 octeți consecutivi în RAM, iar compilatorul înregistrează ce adrese exacte sunt rezervate pentru acesta.

Declararea unei variabile îi spune compilatorului ce tip și dimensiune trebuie să rezerve, în timp ce definiția sau atribuirea stochează de fapt o valoare în acea locație rezervată. De exemplu, scrierea int j; anunță doar variabila și permite compilatorului să aloce memorie, în timp ce j = 10; scrie valoarea numerică 10 în celulele de memorie care aparțin j.

Intern, compilatorul păstrează o tabelă de simboluri unde mapează fiecare nume de variabilă la adresa și tipul său de memorie. Dacă compilatorul decide că j locuiește la adresa 2020, te poți gândi conceptual la situație astfel: identificatorul j indică adresa 2020, iar octeții de la adresa 2020 conțin reprezentarea binară a lui 10.

Este esențial să se separe ideea de „unde este stocat ceva” (adresa sa) de „ce este stocat acolo” (valoarea sa). În teoria compilatoarelor și în multe cărți, locația este adesea numită lvalue (de la „valoare locație”), în timp ce conținutul este denumit rvalue . Pointerii au ca scop manipularea directă a acelor locații.

Ce este mai exact un pointer?

noțiuni de bază despre pointer

Un pointer este pur și simplu o variabilă a cărei valoare este o adresă de memorie care indică un alt obiect. Nu stochează datele în sine, ci adresa unde se află acele date. Dimensiunea unui pointer depinde de arhitectura mașinii: pe sistemele x86 pe 32 de biți este de obicei de 4 octeți, pe sistemele x86-64 pe 64 de biți este de obicei de 8 octeți, iar pe microcontrolere mici precum Arduino o adresă poate încăpea în 2 octeți.

Când declari un pointer, specifici nu doar faptul că acesta stochează o adresă, ci și tipul obiectului către care va indica. De exemplu, int* p declară un pointer către un intSteaua de aici face parte din tip, nu este un semn de înmulțire și îi spune compilatorului câți octeți să citească sau să scrie atunci când accesați ulterior *p.

Operatorul de adresă & vă oferă adresa unui obiect existent, pe care o puteți stoca într-o variabilă pointer. Să presupunem că ai int n = 0;; atunci acest cod stochează adresa lui n într-un pointer:

Exemplu: int n = 0;
int* p = &n; // p now holds the address of n

Odată ce un pointer deține o adresă validă, operatorul de dereferențiere * vă permite să accesați obiectul care se află la acea adresă. If p este un indicator către int, Apoi *p se comportă ca un alias pentru întregul stocat în memorie. De exemplu:

Fragment: *p = 1; // writes 1 into n through the pointer
std::cout << *p; // reads the current value of n

Ideea cheie este că steaua are semnificații diferite în contexte diferite: atunci când este utilizată într-o declarație, formează tipul pointerului, iar atunci când este utilizată într-o expresie, dereferențiază pointerul. Confuzia acestor două roluri este una dintre greșelile clasice ale începătorilor, așa că fiți întotdeauna atenți dacă declarați un pointer sau îl utilizați pentru a accesa memoria.

Pe dispozitive mici, cum ar fi Arduino, un pointer care nu este inițializat explicit fie deține o adresă validă pe 16 biți, fie conține date nedorite. Nu există o valoare magică „goală” decât dacă o setați în mod deliberat la o constantă pointer nulă, cum ar fi nullptr în C++. Dereferențierea unei astfel de adrese inutile este o modalitate aproape sigură de a bloca microcontrolerul.

Corectitudinea constantelor și diferite tipuri de pointeri

Indicatorii interacționează cu const în moduri care pot fi confuze la început, dar stăpânirea acestui aspect este crucială pentru scrierea corectă a limbajului C++. Poziția const relativ la stea decide dacă obiectul spre care se indică, indicatorul în sine sau ambele sunt imuabile.

Dacă aveți un număr întreg constant, tipul pointerului trebuie să reflecte faptul că îl puteți citi doar, nu și modifica. Imaginați-vă acest cod:

Demo: auto const cn = int{0}; // cn is a constant int
int const* p = &cn; // pointer to const int

Tipul de p iată „pointer către un număr întreg constant”: puteți citi *p dar nu i se poate atribui. încercat int* p = &cn; ar fi o eroare de tip, deoarece asta ar promite că poți modifica un obiect constant, lucru interzis de limbaj.

Uneori, obiectul în sine nu este o constantă, dar intenționat doriți un pointer care să permită doar accesul de citire prin intermediul său. În acest caz, folosești din nou int const*:

Utilizare: auto n = int{0}; // non-const int
int const* p = &n; // can read n via p, but not write through p

Observa asta int const* și const int* înseamnă exact același lucru: numărul întreg este doar în citire prin intermediul pointerului, dar pointerul poate fi în continuare modificat pentru a indica în altă parte. Pe de altă parte, dacă scrii int* const p = &n;, tu ai indicator constant către un întreg non-const: adresa stocată în p nu poate fi modificată după inițializare, dar valoarea *p este liber să varieze.

Puteți chiar combina ambele forme pentru a crea un pointer constant către un număr întreg constant: int const* const p. Asta îi spune compilatorului că nici adresa din p nici valoarea stocată la acea adresă nu are voie să se modifice. Înțelegerea acestor variații vă ajută să exprimați intenția foarte clar, iar compilatorul vă va menține onestitatea.

Pointeri către structuri și clase

Când un pointer se referă la o structură sau la o clasă, de obicei se dorește accesarea interfeței sale publice: membrii de date și funcțiile membre. Dereferențiere cu * încă funcționează, dar sintaxa poate deveni puțin cam lungă, așa că C++ oferă operatorul săgeată -> ca o prescurtare.

Luați în considerare un simplu Student structură cu note și o metodă care calculează media. If Student* p deține adresa unui Student obiect, poți scrie (*p).grade_2 să ajungă în clasa a doua sau (*p).average() pentru a apela funcția membru.

Operatorul săgeată combină dereferențierea și accesul la membri într-un singur pas: p->grade_2 și p->average() înseamnă exact același lucru ca (*p).grade_2 și (*p).average(). Sub capotă, p->member este pur și simplu zahăr sintactic pentru (*p).memberDe aceea veți vedea aproape întotdeauna -> utilizat în codul din lumea reală atunci când se lucrează cu pointeri către obiecte.

Atâta timp cât clasa nu se supraîncarcă operator* or operator-> cu un comportament exotic, poți trata p->member ca modalitate standard de accesare a obiectului din spatele unui pointer. Multe framework-uri se bazează pe supraîncărcarea acestor operatori pentru pointerii inteligenți, dar conceptual, păstrează aceeași semnificație: urmăresc pointerul și apoi accesez membrul.

Indicatori nuli și siguranță

Un pointer care nu se referă în prezent la un obiect valid se numește nul, iar în C++ modern, modul canonic de a exprima acest lucru este cu nullptr. Scris int* p = nullptr; afirmă explicit că p nu indică încă nicăieri semnificativ.

Dereferențierea unui pointer nul este un comportament nedefinit, care duce de obicei la blocări, încălcări de acces sau, pe plăci mici, la blocarea sistemului. De aceea, codul care primește un pointer ca parametru verifică adesea dacă acesta este nul înainte de a-l utiliza. Dacă logica permite „fără obiect” ca stare semnificativă, un parametru pointer este potrivit, deoarece poate transporta acea informație „absentă” prin nullptr.

Un exemplu idiomatic este o funcție care convertește un șir de caractere în stil C (char const*) Pentru a std::string dar trebuie să gestioneze cu grație cazul în care pointerul de intrare este nul. Funcția verifică dacă pointerul este non-null înainte de a construi std::stringDacă este nul, returnează un șir gol în loc să dereferențieze o adresă nevalidă.

Dacă parametrul este obligatoriu și nu poate lipsi, referințele C++ sunt de obicei o alegere mai bună decât pointerii bruti. O referință nu poate fi reintrodusă și nu este menită să fie nulă, astfel încât sistemul de tipuri exprimă clar așteptarea ca apelantul să furnizeze un obiect valid. Acest lucru face ca API-ul să fie mai sigur și codul să fie mai ușor de raționat.

Pointeri ca parametri de funcție: prin valoare vs. prin referință

În mod implicit, atunci când transmiteți o variabilă unei funcții în C sau C++, aceasta este transmisă prin valoare: funcția primește o copie a valorii argumentului, nu variabila originală. Aceasta înseamnă că orice atribuire a parametrului din interiorul funcției afectează doar copia locală și lasă variabila apelantului neschimbată.

Acest comportament este adesea dezirabil – izolează funcțiile și evită efectele secundare surprinzătoare – dar uneori chiar doriți ca o funcție să modifice variabilele apelantului. V-ați putea gândi să utilizați variabile globale, dar pe măsură ce programele cresc, variabilele globale devin rapid greu de urmărit și predispuse la erori.

Pointerii oferă o alternativă curată: transmiți adresa variabilei funcției, iar funcția poate apoi modifica valoarea la acea adresă. Aceasta se numește „transmitere prin referință prin pointeri”. În C++, puteți utiliza și parametri de referință (int&), care sunt adesea și mai clare, dar înțelegerea formei pointerului este încă esențială.

Imaginează-ți o funcție double_value care ar trebui să dubleze un număr întreg definit în apelant. Folosind o interfață bazată pe pointeri, ați declara-o ca luând un int*și apelați-l prin transmiterea adresei variabilei dvs.: double_value(&k);În interiorul funcției, *k = *k * 2; actualizează valoarea inițială prin intermediul pointerului.

Această tehnică permite, de asemenea, unei funcții să „returneze” eficient rezultate multiple prin modificarea mai multor variabile ale căror adrese au fost transmise ca argumente. În loc să returnați o structură complexă, puteți accepta mai mulți parametri pointer și să îi actualizați pe toți. În C++ modern, de obicei, ați prefera referințele, tuplurile sau structurile pentru claritate, dar parametrii pointerului rămân comuni în API-urile de nivel scăzut și în bibliotecile C.

Aritmetica pointerilor și tablourile

Unul dintre cele mai puternice – și periculoase – aspecte ale pointerilor este aritmetica pointerilor, în special în contextul tablourilor. În C și C++, un tablou este stocat ca un bloc de elemente contigue în memorie, iar numele tabloului se poate transforma într-un pointer către primul său element atunci când este transmis unei funcții sau utilizat în anumite expresii.

Dacă declarați char h[] = {'P','r','o','m','e','t','e','c','\n'};, Apoi h poate fi tratat ca un indicator către h[0]. accesarea h[i] este conceptual echivalent cu calculul *(h + i), În cazul în care h este adresa de bază și i este offset-ul în elemente (nu în octeți). Compilatorul înmulțește i de dimensiunea fiecărui element (1 octet pentru char, 4 octeți pentru int, etc.) înainte de a-l adăuga la indicator.

Asta înseamnă că atunci când vezi o expresie precum *(h + i), faci aritmetică clasică a indicatorului: avansezi indicatorul h by i poziții și apoi dereferențierea rezultatului. Din motive de performanță, compilatoarele sunt foarte bune la optimizarea acestui model, motiv pentru care tablourile și pointerii în C au fost din punct de vedere istoric o combinație atât de populară pentru lucrul la nivel scăzut.

De asemenea, puteți crea un pointer explicit către primul element al unui tablou și puteți incrementa acel pointer pentru a parcurge tabloul. De exemplu, declararea char* ptr = h; și apoi imprimarea în mod repetat *ptr++ într-o buclă va parcurge fiecare caracter în secvență. Postfixul ++ avansează indicatorul după fiecare accesare, mutându-l la următorul element al matricei.

Acest stil compact este idiomatic în C, dar poate fi criptic pentru începători, așa că în C++ modern mulți dezvoltatori preferă forme mai explicite, cum ar fi for bucle cu indici sau bucle for bazate pe intervale. Totuși, înțelegerea aritmeticii pointerilor este indispensabilă pentru citirea și întreținerea codului vechi, precum și pentru implementarea rutinelor critice pentru performanță.

Memorie dinamică, funcții noi/șterse și iterație de pointer

Pointerii sunt, de asemenea, identificatorul fundamental pe care îl primiți atunci când alocați dinamic obiecte în depozitul gratuit (adesea numit informal heap). În C++, operatorul new returnează un pointer către obiectul nou alocat și delete eliberează acea memorie atunci când nu mai ai nevoie de ea.

De exemplu, Student* p = new Student{...}; rezervă suficientă memorie pentru unul Student obiect și returnează adresa acestuia. Apoi folosești p->member pentru a accesa membrii săi sau a apela metodele sale. Când obiectul nu mai este necesar, delete p; o distruge și eliberează memoria înapoi în magazinul gratuit.

C++ permite, de asemenea, alocarea dinamică a tablourilor folosind new[], care returnează un pointer către primul element al matricei. De exemplu, Student* p = new Student[100]; alocă spațiu pentru 100 Student obiecte așezate contiguu în memorie, cu p indicând elementul de la indicele 0.

Folosind aritmetica pointerilor, expresia p + i indică către ial doilea element al acelui tablou, deci (p + 4)->grade_1 este echivalent cu p[4].grade_1. Conceptual, p este ca un iterator care începe de la primul element și p + i avansează acel iterator prin i pași de-a lungul matricei.

Diferențele dintre pointeri au și o semnificație: dacă q = p + 4;, Apoi q - p se evaluează la 4, care este numărul de elemente dintre cei doi pointeri. În acest sens, un pointer brut este cea mai simplă formă de iterator cu acces aleatoriu. Multe containere STL expun iteratoare care se comportă similar, dar ascund detaliile brute ale pointerului pentru siguranță și flexibilitate.

Deși crud new/delete sunt puternice, C++ modern încurajează insistent utilizarea pointerilor inteligenți și RAII (Resource Acquisition Is Initialization - Achiziția de resurse este inițializarea) pentru a gestiona automat resursele. Indicatori inteligenți precum std::unique_ptr și std::shared_ptr încapsulează proprietatea și eliberează automat memorie atunci când aceasta nu mai este necesară, reducând riscul de scurgeri de date și ștergeri duble.

Pointeri către pointeri și indirecționare mai profundă

Odată ce te familiarizezi cu pointerii simpli, vei întâlni inevitabil pointeri către pointeri (și uneori chiar niveluri mai ridicate de indirecționare). Conceptual, un pointer către pointer este doar o altă variabilă care deține adresa unei variabile pointer, în loc să facă referire directă la un int, double sau obiect.

Cel mai evident caz de utilizare este gestionarea tablourilor de pointeri alocate dinamic, cum ar fi un tabel construit dinamic de șiruri de caractere alocate dinamic. În C simplu, un exemplu clasic este char** argv în main funcție, care este un pointer către o matrice de șiruri de caractere în stil C, fiecare dintre acestea fiind ea însăși o char*.

Un alt scenariu frecvent este atunci când o funcție trebuie să modifice un pointer furnizat de apelant, nu doar datele la care indică. Transmiterea unui pointer către un pointer permite funcției să schimbe obiectul la care se referă pointerul original sau să îl inițializeze prin alocarea unui nou obiect cu new or mallocCodul apelant vede apoi valoarea actualizată a indicatorului.

Niveluri multiple de indirectare apar în mod natural și în anumite structuri de date, în special în structurile de date legate create pe heap. De exemplu, o listă înlănțuită construită dinamic de noduri alocate cu malloc or new poate implica pointeri către noduri, plus funcții care primesc pointeri către acești pointeri pentru a insera sau elimina elemente în timp ce actualizează pointerul head.

Și, bineînțeles, tablourile dinamice multidimensionale sunt de obicei reprezentate ca pointeri către pointeri în interfețele în stil C: o „matrice” este de obicei modelată ca int**, unde fiecare element al primei dimensiuni este un pointer către o matrice de rânduri. În C++ modern, s-ar putea să preferați std::vector<std::vector<T>> sau clase matrice personalizate, dar machetele pointer-to-pointer rămân fundamentale în API-urile și legăturile de nivel scăzut.

Capcane frecvente și bune practici cu indicii

Lucrul direct cu pointerii vă oferă un control detaliat, dar deschide și calea către erori subtile și greu de depanat dacă nu sunteți disciplinat. Multe erori legendare din bazele de cod C și C++ se rezumă la gestionarea greșită a adreselor brute, fie prin scrierea în memorie care nu vă aparține, fie prin uitarea gestionării duratei de viață a obiectelor.

O greșeală clasică este scrierea mai multor octeți decât poate conține tipul țintă sau interpretarea greșită a tipului locației de memorie. De exemplu, dacă depozitați un long valoare într-un int variabilă sau scrieți o long într-o locație care are dimensiunile necesare doar pentru un int, ajungi să suprascrii memoria adiacentă, putând corupe alte variabile sau chiar pointeri de cod.

Un alt pericol este atribuirea directă a valorilor numerice arbitrare variabilelor pointer, cum ar fi ptrNum = 7;, cu excepția cazului în care efectuați lucrări de sistem la nivel extrem de scăzut și știți exact ce se află la acea adresă. Pentru codul de aplicație obișnuit, tratarea unui număr întreg ca adresă este o linie directă către un comportament nedefinit și erori neregulate.

Uitarea inițializării corecte a pointerilor este, de asemenea, riscantă: un pointer care conține o valoare nedeterminată ar putea părea în regulă, dar ar putea indica oriunde în memorie. Inițializați întotdeauna pointerii – fie la o adresă validă, fie la nullptr – și verificați-le înainte de a dereferenția dacă există vreo îndoială că ar putea fi nule.

În cele din urmă, cu memoria dinamică, fiecare element brut new ar trebui să fie potrivit cu exact unul corespondent delete, și fiecare new[] cu exact unul delete[]. Pierderile de date apar atunci când pierzi evidența memoriei alocate dinamic fără a o șterge, iar ștergerile duble (sau ștergerea memoriei pe care nu o deții) corupe structurile interne ale alocatorului, care de obicei se manifestă ca defecte intermitente și foarte greu de reprodus.

Manevrați cu grijă, pointerii sunt mai degrabă un instrument ascuțit și bine echilibrat decât o sursă aleatorie de haos: vă permit să raționați asupra structurii memoriei programului dvs., să proiectați structuri de date eficiente și să construiți abstracțiuni puternice pe lângă acel control de nivel scăzut. Pe măsură ce exersați, trecerea între adrese, dereferențierea și înțelegerea modului în care funcțiile și tablourile interacționează cu pointerii devin o a doua natură, iar teama inițială face loc unui respect sănătos și unei utilități practice considerabile.

Postări asemănatoare: