Un punter és una variable que conté una adreça de memòria. Per tant, és dues coses alhora:
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).
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.
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
caràcters, és a dir 1000 milions!).
* abans del nomPer 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!
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ò... 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`
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++).
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.
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).
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.
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!
swap amb puntersUn 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.
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.
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ó | Iteradors | Punters |
|---|---|---|
| Inicialització | auto it = v.begin(); | int *pa = &a; |
| Assignació | it = v2.erase(it); | pa = &b; |
| Accés al valor | cout << *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.
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.
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.
newPer 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:
Data (això es fa en temps de
compilació).Data indicant que l'objecte nou
està en aquella adreça.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;
new perduren fins que volguemLa 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.
deleteEn 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
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?
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).
delete de un nullptr no és un errorDe 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:
nullptr.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.)