Variabile, referințe și pointeri

Comportamentul obișnuit al operatorului de atribuire (=) în C++ este să copieze valoarea unei variabile în alta. Asta înseamnă că există 2 blocuri de memorie complet separate alocate și fiecare variabilă poate fi modificată independent de cealaltă.

Referințe

Acest comportament nu este singurul posibil. Cel mai probabil că sunteți deja familiari cu noțiunea de "referințe":

void f(int x, int &y) {
    x++;
    y++;
    cout << x << ' ' << y << '\n';
}
...
int main() {
    int a = 0;
    int b = 5;
    f(a, b);
}

În acest exemplu, e impropriu să spunem că y își poate modifica valoarea în afara funcției. Atât x cât și y își modifică valoarea în interiorul funcției, dar există 4 variabile în total.

Snippetul de cod de mai devreme este echivalent cu:

int main() {
    int a = 0;
    int b = 5;
    // Începe "f"
    int x = a;
    int &y = b;
    cout << x << ' ' << y << '\n';
}

** TODO desen 2 referințe pe aceeași adresă **

Poate că acel int &y = x arată mai puțin cunoscut decât exemplul din semnătura funcției, dar se aplică același concept. y este o referință la variabila b. Există un singur bloc de memorie de 4 bytes alocat, și ambele variabile lucrează pe ea la comun. Așadar, dacă ar fi să executăm y = 10 după ultimul snippet, și b ar avea valoarea 10.

O scurtă introducere în concepte de memorie

Pentru a putea vorbi despre pointeri, trebuie pusă in context puțin noțiunea de memorie. Vom revizita subiectul în mult mai mult detaliu în cursurile următoare, dar pentru moment, ce trebuie să știți e că fiecare variabilă ocupă o spațiu în RAM.

RAM, disk, și cum lucrăm cu fiecare

Atunci când spunem memorie, mereu ne referim la RAM. Laptopurile moderne au, de obicei, pe undeva între 8GB si 16GB de RAM, cu unele mai performante aflându-se pe la 32GB. Aici se declară și variabile indiferent dacă sunt scalari, tablouri, declarate local, global sau alocate dinamic. Reamintim și conversiile între unitățile de măsura a spațiului de stocare:

  • 8b (bit) = 1B (Byte)
  • 1000B (Byte) = 1KB (Kilobyte)
  • 1000KB (Kilobyte) = 1MB (Megabyte)
  • 1000MB (Megabyte) = 1GB (Gigabyte)
  • 1000GB (Gigabyte) = 1TB (Terabyte)

Spațiul pe disk este unde instalați aplicații și țineți directoare (folder) si fișiere (file). Pentru a interacționa cu diskul, trebuie deschise fișiere care pot fi citite, scrise, mutate, sau orice altceva ne permite sistemul de operare să facem cu ele.

Tipuri de date și dimensiunile lor

Tipurile de date cu care suntem obișnuiți: int, long long, float și așa mai departe nu trebuie să aibă neapărat o dimensiune fixă în memorie. Nu scrie nicăieri în standardele de C sau C++ că aceste tipuri de date trebuie să fie de o anumită dimensiune, ceea ce înseamnă că o arhitectură nouă de procesor sau chiar și un compilator mai exotic pe arhitecturile cunoscute ar putea să nu respecte dimensiunile cu care suntem noi obișnuiți (mai mult despre asta în cursurile următoare). Acestea fiind zise, în mediul în care scrieți cod la liceu e aproape sigur că veți avea de a face cu următoarele dimensiuni:

  • bool,char = 1B
  • short,unsigned short = 2B
  • int,unsigned,float = 4B
  • long long,unsigned long long,double = 8B

Atunci când declarăm o variabilă, sistemul de operare ne alocă (en: allocate) spațiul necesar. Din cum sunt gândite sistemele de operare uzuale (MacOS, Windows, Linux și cel mai probabil orice ați mai atins vreodată), ele permit indexarea memoriei la dimensiune de minim 1B. Acesta e motivul pentru care tipul de date bool consumă 8 biți întregi, chiar dacă ar avea în teorie nevoie de unul singur. Pur și simplu nu putem cere mai puțină memorie. Un lucru important de menționat este că atunci când declarăm orice fel de tablouri, memoria alocată este contiguă (en: contiguous) - elementele sunt la rând, unul după celălalt. De exemplu, dacă declarăm int v[100], va fi un singur bloc de 400B.

TODO desen bloc contiguu de memorie

Pointeri

Un pointer este practic o variabilă de tip întreg (dimensiunea diferă) ce reține o adresă de memorie. Putem declara un pointer astfel int *ptr = &x, unde x este o variabilă de tip int.

TODO desen pointer la un int

Anatomia declarării unei variabile

Declararea de mai sus introduce câteva elemente noi. În primul rând, * și & sunt simboluri cu câte 2 semnificații. Hai să le detaliem:

  • Declararea unei referințe: & în int &x = y simbolizează că x este o referință la variabila y de tip int
  • Referențiere: & în &x este adresa de memorie a primului byte utilizat în reprezentarea valorii din variabila x
  • Declararea unui pointer: * în int *ptr = &x simbolizează că ptr reține adresa de memorie de la care începe reprezentarea valorii din variabila x de tip int
  • Dereferențiere: * în *ptr obține valoarea de la adresa reținută în pointerul ptr

intul din față nu este tipul pointerului, ci tipul de date al valorii de la adresa indicată de pointer. În esență, dereferențierea unui pointer la int va forma un int din cei 4B începând cu adresa reținută. Asta înseamnă că după o secvență de instrucțiuni de forma:

unsigned int x = 1094795585; // 01000001 01000001 01000001 01000001
char *ptr = (char *) &x; // pointer la primul byte din x
char ch = *ptr; // valoarea primului byte din x
cout << ch << '\n';

se va afișa caracterul cu codul ASCII 65, 'A'.

Un detaliu important de menționat este că există și pointeri de tip void *. Nu înseamnă ca rețin adresa unei variabile de tip void, asta nu ar avea sens, ci se comportă ca un placeholder pentru atunci când tipul de date nu e cunoscut. Un pointer de tip void * nu poate fi dereferențiat.

Orice pointer poate avea ca valoare o adresă speciala, nullptr. Este un placeholder care înseamnă ca pointerul nu arată către nimic. De obicei, pointerii ar trebui inițializați pe nullptr, și funcțiile care întorc pointeri de multe ori întorc nullptr dacă eșuează. Un pointer care conține această valoare nu poate fi dereferențiat.

Exerciții

  1. Menționați ce afișează următoarea secvență sau dacă are erori de compilare sau de runtime:

    int x = 6;
    int *ptr1 = &x;
    int *ptr2 = ptr1;
    *ptr2 = 4;
    cout << x << ' ' << *ptr2 << '\n';
    

    R: 4 4

  2. Menționați ce afișează următoarea secvență sau dacă are erori de compilare sau de runtime:

    int *ptr = (int *) 2;
    cout << ptr << '\n';
    

    R: 0x2

  3. Menționați ce afișează următoarea secvență sau dacă are erori de compilare sau de runtime:

    int *ptr = (int *) 2;
    cout << *ptr << '\n';
    

    R: Undefined behaviour

  4. Menționați ce afișează următoarea secvență sau dacă are erori de compilare sau de runtime:

    int x = 2;
    int y = 4;
    int *ptr1 = &x;
    int *&ptr2 = ptr1;
    ptr2 = &y;
    cout << *ptr1 << '\n';
    

    R: 4