© 2023 – 2026, Pau Fernándezv0.1.277

PRO2

PRO2

Avaluació
Professorat
Pràctica
•

Punters

•

Les dues zones de memòria d'un programa són la pila i el heap

•

Declarem punters posant un asterisc * abans del nom

•

L'adreça nula és nullptr i indica que un punter no apunta a "res"

•

Per apuntar a una variable, obtenim la seva adreça amb &

•

Un punter pot canviar de valor amb total llibertat

•

Apuntadors a camps

•

Si tenim un punter, podem fer el que volguem amb el valor apuntat

•

Error de segmentació

•

Pas de paràmetres per referència amb punters

•

Exemple, swap amb punters

•

Aliasing

•

Iteradors i punters

•

Element següent

•

Classes

•

Per reservar objectes al heap cridem el seu constructor amb new

•

Les variables creades amb new perduren fins que volguem

•

Per alliberar la memòria d'una variable fem servir delete

•

Un error comú de la gestió de memòria són les "fuites de memòria"

•

Els errors de gestió de memòria són molt diversos

•

El delete de un nullptr no és un error

Punters

Un punter és una variable que conté una adreça de memòria. Per tant, és dues coses alhora:

  1. L'adreça: És una variable que internament conté un número (amb un número de bits igual que el número de bits de la CPU, actualment 64 bits).

  2. El valor: El fet que el número representi una adreça permet anar a buscar el contingut d'aquella adreça, interpretat com a un valor de cert tipus.

El més important quan fem servir punters és no confondre les dues coses. Si deixem una jaqueta a la consigna d'una discoteca, ens donen un paperet amb un número. Es pot veure el paperet com una "adreça" i la jaqueta com el valor. Però el paperet no és la jaqueta. Són coses diferents. El paperet permet obtenir la jaqueta, però no és la jaqueta.

Les dues zones de memòria d'un programa són la pila i el heap

Quan vam fer recursivitat, vam veure que les variables declarades dins d'una funció es troben a la pila d'execució del programa, i aquesta pila és molt petita, en termes del % de memòria disponible en un ordinador normal.

La pila d'execució de la majoria de sistemes operatius és de 8MB o 16MB (és pot ajustar, també, però s'ha de ser bastant pro). En aquest tamany ens hi cap poca cosa, donat que la majoria d'ordinadors tenen uns quants GBs (o unes quantes desenes!), o sigui gigabytes, de memòria! Com s'accedeix a tota aquesta memòria que no és la zona tan minsa que és la pila??

La idea és que un programa té accés a dues zones: la pila i el heap. (Aquest nom és desafortunat perquè just hem estudiat el Heap i aquesta estructura de dades no té res a veure amb la zona de memòria que anomenem heap.) La pila és la zona de 16MB que hem comentat, i el heap és la zona a on podem reservar els trossos de memòria que volguem, només limitats per la memòria disponible a l'ordinador. Per tant, fent servir el heap podriem demanar 1GB de memòria, si calgués (en 1GB hi caben 10910^9109 caràcters, és a dir 1000 milions!).

Declarem punters posant un asterisc * abans del nom

Per declarar un punter, i donat que el punter indica la posició de memòria d'un valor, cal dir de quin tipus és el valor apuntat. Això és lògic perquè si anem a una posició de memòria qualsevol, no sabem d'entrada què representen, aquells bits.

Un punter pot apuntar a un valor de tipus int, a un string o a un vector<bool>, o qualsevol dels altres tipus que hem vist.

int *pa;      // `pa` és un punter a un enter
int *pb, *pc; // `pb` i `pc` són dos punters a enters

Quan declarem un punter i no posem cap valor inicial, no sabem quina adreça té el punter (i això és ben perillós!), però en té una, igual que els enters tenen algun valor, que desconeixem si no l'indiquem explícitament.

Malgrat el * pot anar "enganxat" a int, la convenció més còmoda és "enganxar-lo al nom de la variable". Perquè sinó acabarem pensant que el tipus és int*, i això pot donar lloc a confusió! Fixa't com la declaració de pb i pc té dos asteriscos, un davant de cada variable, i el int va sol!

L'adreça nula és nullptr i indica que un punter no apunta a "res"

Per declarar un punter que "no apunta a res", es fa servir el valor nullptr, que és l'adreça 0. Com que l'adreça 0 d'un ordinador està totalment protegida a l'accés desde programes que no siguin el sistema operatiu, es pot utilitzar per dir que un punter "no apunta a cap valor", malgrat realment el 0 és com un "sentinella", un valor especial amb un significat conegut.

int *pa = nullptr;
int *pb = nullptr, *pc = pa; // pc és també `nullptr`

Per apuntar a una variable, obtenim la seva adreça amb &

Però... com obtenim una adreça d'un valor int, si el volem guardar a pa, o sigui un punter a int? Una manera fàcil és apuntant a l'adreça d'una variable que ja existeix:

int a = 7;
int *pa = &a; // `pa` ara conté l'adreça de `a`
int *pb = pa; // `pb` també té l'adreça de `a`, se l'ha copiat!

Per tant, &a vol dir "obtenir l'adreça de a". Aquest operador & funciona amb qualsevol variable, incloses les caselles d'un vector:

vector<int> v = {-1, -2, -3};
int *pc = &v[2]; // `pc` té l'adreça de la tercera casella de `v`

Un punter pot canviar de valor amb total llibertat

De fet, en tots els exemples, hem inicialitzat els punters en el moment de declarar-los, però els punters poden canviar de valor, com qualsevol variable:

int a = 1, b = 2, c = 3;
int *px = &a;  // ara apunta a `a`
px = &b;       // ara apunta a `b`
px = &c;       // ara apunta a `c`

Fixeu-vos que en la declaració, per dir que px és un punter, hem posat l'asterisc * just abans del nom. Però quan l'hem assignat no. px es com una variable entera amb un enter molt especial, una adreça (que està lligada a un tipus de C++).

Apuntadors a camps

Quan tenim una tupla com:

struct Punt2D { double x, y; };

també podem declarar punters a variable de tipus Punt2D:

Punt2D p = {0, 0}, q = {1, 0};
Punt2D *pp = &p, *pq = &q;

I llavors per accedir als camps hem de fer:

(*pp).x = -0.5;
(*pq).y += 2.1;

Donat que escriure (*pp).x és feixuc i costa més esforç, C ja va inventar un operador més còmode: pp->x, que significa exactament el mateix. La fletxa de pp->x ja és una pista a que pp és un punter, i s'utilitza molt.

Si tenim un punter, podem fer el que volguem amb el valor apuntat

Fins ara, hem utilitzat la part del punter que és una adreça, declarant punters, inicialitzant-los i assignant-los. Però la gràcia dels punters és poder accedir al valor al que apunten i consultar-lo o canviar-lo.

Per fer-ho, cal posar de nou l'asterisc, però davant del nom:

int a = 2;
int *pa = &a; // `pa` apunta a `a`

*pa = 7; // <-- Canviem el valor d'`a`! 👀

cout << a << endl; // 7

Al fer *pa el que demanem és saltar a l'adreça de la variable de tipus int amb adreça pa, que en aquest cas coincideix amb a, perquè tant a com *pa tenen la mateixa adreça.

Part de la confusió amb els punters és el fet que l'asterisc * es fa servir a dos llocs amb significats diferents: 1) a la declaració, per dir que la variable és un punter; i 2) per distingir l'ús de les dues parts del punter, l'adreça (pa) i el valor al que apunta (*pa).

Error de segmentació

A través dels punters és com, amb un 90% de probabilitat, els programes cometen errors d'execució, que els fan malfuncionar i frustren completament els usuaris que els fan servir.

Per exemple, què passa si fem:

int *px = nullptr;
*px = 7;

Hem suposat que hi havia una variable apuntada per px i l'hem anat a canviar. Però l'adreça 0 d'un ordinador és "reservada", o sigui no és accessible per un programa normal.

Quan això passa, el sistema operatiu (the boss) es desperta enfadat i directament aplica el càstig més dur possible, que és matar el procés que ha comès l'error. Per tant si fas un accés erroni a una adreça que no és teva, el teu programa acaba de forma abrupta, com fulminat per un llamp llançat per Déu mateix.

Com que un procés (un programa que s'executa) té "segments" de memòria i si intentes accedir a una adreça que no és de cap dels teus "segments" comets un "error de segmentació", en anglès a aquests errors se'ls diu "segmentation fault" o segfault. A Linux, el sistema operatiu envia un senyal SIGSEGV, i el procés mor a l'instant. Bàsicament, el procés s'ha "portat malament" perquè ha intentat tocar una cosa que no era seva.

Així doncs, treballar amb punters requereix força disciplina mental perquè, al més mínim error, el programa deixa de funcionar. Cal tenir sempre clar quins valors rep un punter, d'on provenen, i si són adreces vàlides.

Pas de paràmetres per referència amb punters

Al llenguatge C, quan no hi havia el pas per referència que coneixem de C++, només hi havia punters, i per tant per passar una variable per referència es passava el seu punter:

void incrementa(int *px) {
    *px = *px + 1;
}

int main() {
    int a;
    incrementa(&a);
}

És a dir, la funció incrementa rep l'adreça d'una variable que cal incrementar, i dins la funció es fa servir la notació *px per anar a l'adreça de la variable i realment canviar el seu valor. Al main, en comptes de passar a, hem de passar la seva adreça.

C++ va entendre que aquest pas per referència donava massa feina (posar & a la crida i posar * abans de cada px a la funció), i es va inventar les referències, de forma que el pas per referència fos més simple. Però per sota, es fan servir punters!

Exemple, swap amb punters

Un exemple de l'ús de punters és intercanviar dos variables a i b sense fer cap assignació a = ...:

int a = 5, b = 13;
int *px = &a, *py = &b;

int c = *px;
*px = *py;
*py = c;

cout << a << ' ' << b << endl; // 13 5

També haguéssim pogut posar swap(*px, *py), perquè swap accepta dues variables del mateix tipus, i *px i *py ho són.

Aliasing

Què passa quan tenim la mateixa adreça a dos punters diferents? Doncs que tenim vàries maneres de manipular la mateixa adreça de memòria:

int a = -4;
vector<int*> pv(5, &a); // vector de punters, tots a `&a`
for (int i = 0; i < pv.size(); i++) {
    *pv[i] += 1;  // incrementem la variable apuntada per v[i]
}
cout << a << endl;

Que sortirà per pantalla? Doncs donat que hi ha 5 punters a a, cada cop que hem fet *v[i] += 1, hem sumat 1 a la mateixa variable, que és a, per tant hem sumat 5 vegades 1 que és com sumar 5. La sortida és 1.

Per cert, quina és la diferència entre (*v)[i], i *(v[i]) i quin és el que s'aplica en aquest exemple?

L'aliasing és un maldecap pels compiladors, perquè sovint, quan volen optimitzar el codi d'una funció, han de tenir la precaució de pensar que dos punters potser apunten a la mateixa adreça (son àlies), i això els impideix aplicar certes optimitzacions.

Iteradors i punters

Tot això dels punters potser és un déjà vu, donat que els iteradors ja feien servir aquesta notació amb l'asterisc *, i també la fletxa ->. Per ser més precisos, els iteradors són una abstracció dels punters, és a dir, es fan servir com a punters però tenen només les operacions mínimes no tenen perquè ser-ho, exactament.

Per fer un resum, posem en una taula les operacions que hem vist fins ara (suposem la declaració vector<int> v, v2;):

OperacióIteradorsPunters
Inicialitzacióauto it = v.begin();int *pa = &a;
Assignacióit = v2.erase(it);pa = &b;
Accés al valorcout << *it;cout << *pa;
Modificació del valor*it = 5;*pa = 5;

Per tant, amb els iteradors no escolliem l'adreça inicial ni podíem posar qualsevol adreça, sinó només els iteradors que ens dona cada contenidor (begin(), end(), etc.). Amb els punters tenim força més llibertat (i facilitat per cometre errors garrafals!). Però en l'apartat d'accés i modificació, punters i iteradors són equivalents, pràcticament.

Element següent

Una cosa que els iteradors tenen i que no hem vist a punters (ni farem servir massa), és el "passar al següent". Si tenim el següent programa:

int a;
int *pa = &a;
pa++;          // incrementa l'adreça de `pa` (?)

El que hem fet és inicialitzar pa amb l'adreça de a i tot seguit, hem incrementat pa, o sigui hem demanat la "següent adreça". De fet, com que els enters a C++ ocupen 4 bytes, en realitat l'adreça de pa ha saltat 4 unitats, no una. Per tant els punters fan la suposició que la memòria està plena de valors del mateix tipus tots junts, i si fas ++ passen "al següent".

Però quina adreça és &a + 1, o sigui l'adreça del "següent enter després d'a"??? Ben difícil de saber. En realitat, dependrà de la disposició en què les variables de la funció s'hagin col·locat en memòria, però en aquest cas, podria arribar a passar que pa s'apunti a un tros de sí mateix!

En tot cas, l'"aritmètica de punters", és a dir, la manipulació de punters sumant enters o fent càlculs és difícil i no la treballarem a PRO2 explícitament.

Classes

Fins ara només hem vist punters que adopten adreces de variable, la qual cosa vol dir que encara no hem vist com accedir a tots aquells GBs de memòria que prometiem al principi! Aquesta notícia és la bona.

La dolenta és que quan reserves memòria manualment, et fiques en un bon embolic, perquè llavors et responsabilitzes tu com a programador de crear i destruir cada variable que necessites, que és bastanta feina, i torna a ser un tema espinós, amb possibilitat d'errors de segmentació, etc.

Per reservar objectes al heap cridem el seu constructor amb new

Per reservar memòria, cal fer servir new, un operador que retorna un punter i està força relacionat amb les classes.

Si tenim una classe com la següent:

class Data {
    int dia_, mes_, any_;
public:
    Data() : dia_(1), mes_(1), any_(2000) {}
    Data(int dia, int mes, int any)
        : dia_(dia), mes_(mes), any_(any) {}
    // ...
};

la forma de reservar una porció de memòria (a la zona sense límit!) per a un objecte de la classe Data és, realment, com cridar a un constructor amb el prefix new:

Data *pdata = new Data(7, 4, 2026);

L'operador new fa molta feina:

  1. Esbrinar el tamany d'un objecte Data (això es fa en temps de compilació).
  2. Demanar tants bytes com siguin necessaris en una zona contígua (aquesta operació retorna un punter).
  3. Cridar al constructor de la Data indicant que l'objecte nou està en aquella adreça.
  4. Retornar el punter a la nova zona.

Un cop fet tot això, hem creat una "variable" que no té nom, realment, perquè no tenim cap variable amb aquell contingut, només en tenim el punter. Sabem on està, però no hi ha cap part del programa que hi accedeixi directament.

El new, en realitat no només és per a classes, també es pot fer servir amb tipus bàsics (malgrat això és molt poc freqüent):

int *pa = new int;
bool *pb = new bool;
char *pc = new char;

Les variables creades amb new perduren fins que volguem

La gràcia de controlar nosaltres la creació i destrucció de les variables és una altra, però. Fins ara, les variables tenien un cicle de vida controlat per la funció a la que estan "encadenades". És a dir, tota variable vivia dins d'un parell de claus {}. Per exemple,

int f(int a, int b) {
    int m = 2 * (a % b);
    return m;
}

a la funció f, la variable m "neix" quan entrem a la funció, i un cop es fa return la variable m "mor", és a dir, deixa d'existir. Que vol dir que a la seva zona de memòria hi haurà altres coses, i m ja no hi serà.

Però la següent funció és molt diferent:

int *fnew(int a, int b) {
    int *pm = new int;
    *pm = 2 * (a % b);
    return pm;
}

En aquesta funció fnew, la variable pm és un punter, i malgrat pm també "mor" en acabar la funció, l'adreça de memòria a la que apuntava (l'autèntica "variable") segueix existint, i com que fnew retorna aquest punter, el podem recollir al cridar fnew així:

int *p = fnew(5, 12);

Que vol dir que hem pogut crear una variable que no es "mor" quan sortim de la funció, sinó que perviu. Això és important per tenir objectes en un programa que tenen un cicle de vida que controlem nosaltres, i no l'estructura de funcions del programa.

Per alliberar la memòria d'una variable fem servir delete

En el moment que no necessitem una variable que haguem creat abans amb new perquè ja no la necessitem, cal alliberar la memòria que ocupa. Això es deu al fet que, realment, el programa internament té una llista completa dels trossets de memòria que té reservats en un moment donat, i si demanes més memòria, evidentment no et dóna la que està marcada com "en ús", et dóna un trosset que estigui lliure.

Per alliberar un tros de memòria, cal indicar el seu punter (punter a la primera adreça del tros):

int *pa = new int; // reservem un enter

// ... utilitzem l'enter a través de `pa` ...

delete pa;         // alliberem l'enter

Un error comú de la gestió de memòria són les "fuites de memòria"

Si cada cop que crees una variable, després has d'alliberar-la, és ben fàcil no recordar fer-ho en algun cas. Quan comets aquest error una part de la memòria queda marcada com "en ús" quan realment no es fa servir, i això pot portar, si vas creant variables en molta quantitat, a que et quedis sense memòria (moment en el qual the boss es desperta tot enfadat i ja podeu pensar què passa llavors). A aquesta situació se l'anomena un "memory leak" (una "fuita de memòria").

És ben fàcil, realment, crear una fuita de memòria, és suficient amb "perdre" un punter:

int *px;
px = new int; // primera variable
px = new int; // segona variable

En aquest exemple, donat que l'adreça de la primera variable que hem guardat a px s'ha sobreescrit amb la segona, no tenim manera de recuperar l'adreça de la primera! I ara què? Doncs que no podrem alliberar la primera i tenim 4 bytes permanentment marcats com "en ús" però que no podem utilitzar. Mecachis.

L'analogia seria que vas a una discoteca, li entregues la teva jaqueta al guardaroba, et dóna un paperet, i vas i al cap d'una estona et despistes (no sé perquè...) i vas i perds el paperet. Si el guardaroba de la discoteca és com la memòria d'un ordinador, tindrà 1000 milions de jaquetes, com a mínim. I qui és el towapo que busca la jaqueta ara que no tens el paperet, eh?

Els errors de gestió de memòria són molt diversos

No només tenim les "fuites de memòria", o memory leaks. De fet hi ha almenys 3 errors possibles força típics:

  • Memory leak: no alliberes una adreça reservada amb new i perds el punter. Una zona de la teva memòria està marcada com a necessària però no la pots fer servir.

  • Dangling pointer ("punter penjim-penjam"): has alliberat un punter, però t'has oblidat que ho havies fet i després se t'acudeix utilitzar-lo. Donat que potser ja s'està fent servir per a alguna altra cosa, interpretaràs els bytes que algú altre ha guardat allà com un objecte del tipus que esperaves, amb resultats típicament desastrosos.

  • Double delete ("esborrat doble"): has alliberat un punter, però en un altre moment ho tornes a fer perquè no te'n recordaves. Això produeix un error d'execució perquè quan el gestor de memòria del programa va a mirar a la llista dels trossos en ús, no veu el punter que li has passat i entra en pànic (i el procés es suïcida).

El delete de un nullptr no és un error

De fet hi ha una operació que també semblaria un error i és:

int *pa = nullptr;
delete pa;

però aquesta no ho és per permetre un patró típic i és:

  • Tenir un punter inicialitzat amb nullptr.
  • Si cal, reservar memòria, però potser no.
  • Fer delete del punter incondicionalment.

Si al fer delete, tenim la garantia que funcionarà tant si és un punter que cal alliberar com si és nullptr, podem posar el delete sempre, i ja s'encarrega ell de veure si cal alliberar o no, en funció de si el punter és nullptr (zero) o no. (Aquesta tècnica la farem servir a la nostra classe Vector.)