Pointers și tablouri
Relația dintre pointeri și tablouri
În C++, pointerii și tablourile sunt strâns legate. De regulă, compilatorul convertește automat un tablou într-un pointer. Cu ajutorul pointerilor putem manipula elementele unui tablou la fel ca prin intermediul indicilor.
Numele unui tablou este, în esență, adresa primului său element. Prin urmare, prin operația de dereferențiere putem obține valoarea de la acea adresă:
#include <iostream>
int main()
{
int nums[] {1, 2, 3, 4, 5};
std::cout << "nums[0] address: " << nums << std::endl;
std::cout << "nums[0] value: " << *nums << std::endl;
Ieșire:
nums[0] address: 0x1f1ebffe60
nums[0] value: 1
Adunând un număr la adresa primului element, putem obține alte elemente din tablou:
#include <iostream>
int main()
{
int nums[] {1, 2, 3, 4, 5};
int num2 = *(nums + 1); // al doilea element
int num3 = *(nums + 2); // al treilea element
std::cout << "num2 = " << num2 << std::endl; // num2 = 2
std::cout << "num3 = " << num3 << std::endl; // num3 = 3
}
Adică, de exemplu, adresa celui de-al doilea element este reprezentată de expresia nums + 1, iar valoarea acestuia — *(nums + 1).
În ceea ce privește adunarea și scăderea, aici se aplică aceleași reguli ca și în operațiile cu pointeri. Adunarea unei unități înseamnă adăugarea la adresă a unei valori egale cu dimensiunea tipului de date al tabloului. Astfel, în acest caz, tabloul este de tip int, a cărui dimensiune este, de regulă, 4 bytes, deci adunarea unei unități înseamnă creșterea adresei cu 4. Dacă adunăm 2, atunci adresa va fi mărită cu 4 × 2 = 8. Și așa mai departe.
De exemplu, să parcurgem toate elementele într-un ciclu:
#include <iostream>
int main()
{
int nums[] {1, 2, 3, 4, 5};
for(unsigned i{}; i < std::size(nums); i++)
{
std::cout << "nums[" << i << "]: address=" << nums+i << "\tvalue=" << *(nums+i) << std::endl;
}
}
Și în cele din urmă, programul va afișa în consolă următorul rezultat:
nums[0]: address=0xd95adffc30 value=1
nums[1]: address=0xd95adffc34 value=2
nums[2]: address=0xd95adffc38 value=3
nums[3]: address=0xd95adffc3c value=4
nums[4]: address=0xd95adffc40 value=5
Dar, în același timp, numele unui tablou nu este un pointer obișnuit, și nu putem modifica adresa sa, de exemplu astfel:
int nums[] {1, 2, 3, 4, 5};
nums++; // așa nu se poate
int b {8};
nums = &b; // nici așa nu se poate
Pointeri către tablouri
Numele unui tablou stochează întotdeauna adresa primului său element. Și deseori, pentru a naviga prin elementele tabloului, se folosesc pointeri separați:
int nums[] {1, 2, 3, 4, 5};
int *ptr {nums};
int num3 = *(ptr+2);
std::cout << "num3: " << num3 << std::endl; // num3: 3
Aici, pointerul ptr indică inițial către primul element al tabloului. Mărind pointerul cu 2, vom sări peste 2 elemente din tablou și vom ajunge la elementul nums[2].
Putem de asemenea să atribuim direct adresa unui element anume din tablou pointerului:
int nums[] {1, 2, 3, 4, 5};
int *ptr {&nums[2]}; // adresa celui de-al treilea element
std::cout << "*ptr = " << *ptr << std::endl; //*ptr = 3
Cu ajutorul pointerilor putem parcurge ușor un tablou:
#include <iostream>
int main()
{
const int n = 5;
int nums[n]{1, 2, 3, 4, 5};
for(int *ptr{nums}; ptr<=&nums[n-1]; ptr++)
{
std::cout << "address=" << ptr << "\tvalue=" << *ptr << std::endl;
}
}
Deoarece un pointer stochează o adresă, putem continua bucla atâta timp cât adresa stocată în pointer nu a ajuns la adresa ultimului element.
În mod similar, putem parcurge și un tablou bidimensional:
#include <iostream>
int main()
{
int nums[3][4] { {1, 2, 3, 4} , {5, 6, 7, 8}, {9, 10, 11, 12}};
unsigned int n { sizeof(nums)/sizeof(nums[0]) }; // numărul de rânduri
unsigned int m { sizeof(nums[0])/sizeof(nums[0][0]) }; // numărul de coloane
int *end {nums[0] + n * m - 1}; // pointer la ultimul element: 0 + 3 * 4 - 1 = 11
int *ptr {nums[0]}; // pointer la primul element
for( unsigned i{1}; ptr <= end; ptr++, i++)
{
std::cout << *ptr << "\t";
// dacă restul împărțirii este 0, trecem la o linie nouă
if(i % m == 0)
{
std::cout << std::endl;
}
}
}
Deoarece în acest caz lucrăm cu un tablou bidimensional, adresa primului element este exprimată prin a[0]. Astfel, pointerul indică către acel element. La fiecare iterație, pointerul este incrementat cu o unitate, până când adresa sa devine egală cu cea a ultimului element, stocată în pointerul end.
Am fi putut realiza același lucru fără a folosi un pointer pentru ultimul element, verificând în schimb o variabilă contor:
#include <iostream>
int main()
{
const unsigned n {3}; // numărul de rânduri
const unsigned m {4}; // numărul de coloane
int nums[n][m] { {1, 2, 3, 4} , {5, 6, 7, 8}, {9, 10, 11, 12}};
const unsigned count {m * n}; // numărul total de elemente
int *ptr{nums[0]}; // pointer la primul element al primului sub-tablou
for(unsigned i{1}; i <= count; ptr++, i++)
{
std::cout << *ptr << "\t";
// dacă restul împărțirii este 0, trecem la o linie nouă
if(i % m == 0)
{
std::cout << std::endl;
}
}
}
Dar în ambele cazuri, programul ar afișa următorul rezultat:
1 2 3 4
5 6 7 8
9 10 11 12
Pointer la șiruri și tablouri de caractere
Deoarece un tablou de caractere poate fi interpretat ca un șir, un pointer către valori de tip char poate fi de asemenea interpretat ca un șir:
#include <iostream>
int main()
{
char hello[] {"hello"};
char *phello {hello};
std::cout << phello << std::endl; // hello
}
La afișarea în consolă a valorii pointerului, de fapt, va fi afișat șirul.
Putem folosi și operația de dereferențiere pentru a obține caractere individuale — de exemplu, să afișăm primul caracter:
std::cout << *phello << std::endl; // h
Dacă vrem să afișăm în consolă adresa stocată în pointer, trebuie să o convertim la tipul void*:
std::cout << (void*)phello << std::endl; // 0x60fe8e
În rest, lucrul cu un pointer către un tablou de caractere se face la fel ca în cazul pointerilor către tablouri de alte tipuri.
De asemenea, cum un pointer de tip char poate fi interpretat ca șir, teoretic putem scrie și astfel:
char *phello {"hello"};
Totuși, trebuie reținut că șirurile literale în C++ sunt tratate ca constante. De aceea, definiția de mai sus poate genera cel puțin un avertisment la compilare, iar o încercare de a modifica caracterele prin intermediul pointerului va duce la o eroare de compilare. Așadar, la definirea unui pointer către un șir literal, este recomandat să folosim:
#include <iostream>
int main()
{
const char *phello {"hello"}; // pointer către constantă
std::cout << phello << std::endl; // hello
}
Tablouri de pointeri
Putem de asemenea să definim tablouri de pointeri. Într-un anumit sens, un tablou de pointeri seamănă cu un tablou care conține alte tablouri. Totuși, un tablou de pointeri are anumite avantaje.
De exemplu, să luăm un tablou bidimensional de caractere — un tablou care stochează șiruri:
#include <iostream>
int main()
{
char langs[][20] { "C++", "Python", "JavaScript"};
std::cout << langs[0] << ": " << std::size(langs[0]) << " bytes" << std::endl; // C++: 20 bytes
}
Pentru definirea unui tablou bidimensional trebuie să specificăm cel puțin dimensiunea tablourilor interne, care să fie suficientă pentru a încăpea fiecare șir. În acest caz dimensiunea fiecărui tablou intern este de 20 de caractere. Însă de ce să alocăm 20 de bytes pentru primul șir – "C++", care conține 4 caractere (inclusiv octetul nul final)? Aceasta este o limitare a unor astfel de tablouri. Tablourile de pointeri însă permit evitarea acestei limitări:
#include <iostream>
int main()
{
const char *langs[] { "C++", "Python", "JavaScript"};
// parcurgerea tabloului
for(unsigned i{}; i < std::size(langs); i++)
{
std::cout << langs[i] << std::endl;
}
}
În acest caz, elementele tabloului langs sunt pointeri: 3 pointeri, fiecare ocupând 4 sau 8 bytes în funcție de arhitectură (dimensiunea adresei). Fiecare dintre acești pointeri indică spre o adresă în memorie unde este stocat șirul corespunzător: "C++", "Python", "JavaScript". Totuși, fiecare dintre aceste șiruri va ocupa exact atâta spațiu cât are nevoie. Adică șirul "C++" va ocupa 4 bytes. Pe de o parte, aici apar costuri suplimentare: se alocă memorie suplimentară pentru stocarea adreselor în pointeri. Pe de altă parte, atunci când șirurile din tablou diferă mult ca lungime, putem obține un câștig general în ceea ce privește memoria utilizată.