< Home
Stampa

Visibilità delle variabili

Sommario

La memoria del processo

Riprendiamo lo schema della memoria già visto in questa lezione.

notion image

I due fondamentali blocchi di memoria sono:

  • lo heap, che contiene la memoria dinamicamente creata del programma;
  • lo stack, che contiene la memoria locale usata dalle funzioni.

Lo stack

Nello stack è memorizzata qualsiasi variabile locale di una funzione. Questa variabile può essere di qualsiasi tipo, ad esempio un tipo base, un array/puntatore o qualsiasi oggetto.

Lo stack accumula le variabili interne di ogni funzione. Se ad esempio la funzione a richiama la funzione b, le variabili locali della funzione b verranno memorizzate nello stack “sopra” le variabili di a. Al termine della funzione b, queste verranno eliminate e lo stack tornerà ad essere quello a disposizione della funzione a. Questo meccanismo garantisce che ogni funzione veda le “sue variabili” e che le distrugga una volta terminata. E’ una operazione molto veloce, è sufficiente che il puntatore alla testa dello stack (detto “stack pointer”) venga spostato al punto in cui era quando è stata chiamata b.

Esempio:

int sum(int a, int b) {
    return a+b;
}

int op(int a, int b, int c) {
    return sum(a,b)*c;
}

int main() {
    int a=1, b=2, c=3;
    cout << op(a,b,c);
}
  1. All’avvio viene eseguito main che carica a,b,c nello stack.
  2. Quando viene eseguita op(a,b,c), il programma crea una copia di a,b,c e le mette nello stack.
  3. op richiama sum(a,b), viene quindi creata una copia di a,b che viene messa nello stack.
  4. A questo punto sum esegue la somma e la restituisce ad op (mette il risultato nello stack).
  5. op prende il risultato dallo stack e riporta lo stack come al punto 2
  6. Op esegue la moltiplicazione e restituisce a main (lo mette nello stack).
  7. Main riceve il risultato e riporta lo stack come al punto 1.

Questo meccanismo si rivela molto veloce ed efficace, i parametri sono sempre copiati e così i valori di ritorno, ed ogni funzione quando viene eseguita ha una propria area di memoria protetta che viene automaticamente cancellata alla fine dell’esecuzione.

Tuttavia questo meccanismo ha diverse peculiarità:

  • le variabili locali ed i parametri esistono solo finché la funzione è in esecuzione. In particolare i parametri sono copie locali delle variabili originali:
void modify(int x) {
  x=3;
}

int main() {
  int a=0;
  modify(a);
  cout << a; // il risultato resta 0
}
  • per poter modificare una variabile locale quando si chiama una funzione, bisogna passarne il riferimento: viene fatta quindi una copia del riferimento e quindi è possibile grazie ai puntatori modificare variabili fuori dal proprio segmento di stack locale;
void modify(int *x) {
  x=3;
}

int main() {
  int a;
  modify(&a);
  cout << a; // il risultato è 3
}
  • gli array sono puntatori quindi la funzione chiamata modifica sempre l’array originale;
void modify(int x[]) {
  x[0]=3;
}

int main() {
  int x[3] = {1,2,3};
  modify(x);
  cout << x[0]; // il risultato è 3
}
  • non è possibile restituire un array creato nella funzione locale:
char* read() {
  char string[100];
  cin >> string;
  return string;
}

int main() {
  char* string = read();
  cout << string;  // NULL
}

Questo è dovuto al fatto che l’array è una variabile locale come tutte le altre, e viene cancellato all’uscita della funzione.

  • lo stack ha una dimensione limitata. Se si eseguono troppe chiamate, ad esempio ricorsive, lo stack supera il limite di dimensioni, e viene generato un errore a runtime chiamato stack overflow, che genera il crash dell’applicazione.

In conclusione lo stack offre il vantaggio di gestire in modo automatico la creazione e la distruzione delle variabili, ma ha il limite di ridurre la visibilità delle variabili, costringe a crearne delle copie ad ogni chiamata di funzione, e distrugge automaticamente tutto ciò che è creato dentro una funzione.

Se si vogliono superare questi limiti si usa l’heap.

Heap

L’Heap è una memoria dinamica, che svolge principalmente due compiti:

  • consentire di avere uno spazio di memoria condiviso tra funzioni, anche non direttamente collegate, e ridurre sprechi non necessari di memoria e di tempo per le copie;
  • garantire lo sfruttamento della massima memoria possibile consentita per un programma, perché non soffre del problema dello stack overlow (ovviamente resta il limite della memoria massima disponibile).

Per poter creare variabili nello heap utilizziamo un meccanismo di allocazione dinamica, che crea un puntatore ad una cella di memoria nello heap tramite la parola chiave new (in C++, in C è invece utilizzata malloc, qui non descritta).

int* px = new int;

px diventa una variabile che può essere passata tra le funzioni (viene creata una copia del puntatore) senza mai essere effettivamente cancellato l’originale.

int* create() {
  int* x = new int;
  return x;
}

int main() {
  int *x = create();
}

L’utilizzo dello heap rende possibile creare dinamicamente variabili anche dentro le funzioni, che non verranno cancellate alla loro chiusura. Se nell’esempio sopra questo utilizzo è inutile (un intero può essere comunque restituito anche usando solo lo stack), la cosa cambia con un array:

char* read() {
  char* string = new char[100];
  cin >> string;
  return string;
}

int main() {
  char* string = read();
  cout << string; 
}

Diventa quindi possibile creare dinamicamente array in una funzione.

Lo heap permetterà quindi di creare dinamicamente variabili, senza rischi di cancellazione nelle singole funzioni.

Tuttavia le variabili create nello heap devono essere esplicitamente cancellate. Ad esempio:

void read() {
  char* string = new char[100];
  cin >> string;
  cout << "Hello " << string;
}

int main() {
  read();
}

In questo codice, perfettamente funzionante, si cela un errore concettuale: la memoria dell’array char rimane allocata anche dopo che la funzione viene chiusa. Viene cioè generato un memory leak, ovvero quella memoria resta comunque allocata e non c’è più modo di liberarla perché si perde il puntatore a quella cella di memoria. E’ un errore molto grave e può generare l’occupazione errata della memoria.

E’ un problema di programmazione molto vasto, dovuto ad una programmazione non accurata.

In C++ bisogna esplicitamente liberare la memoria usando la parola chiave delete (in C++, in C si usa free() ).

void read() {
  char* string = new char[100];
  cin >> string;
  cout << "Hello " << string;
  delete string;
}

int main() {
  read();
}

Delete libera la memoria occupata senza rischi.

Altri linguaggi di programmazione usano diverse tecniche:

  • il garbage collector: nel processo gira un sottoprocesso secondario (un thread) che controlla periodicamente se ci sono aree di memoria allocate ma senza variabile associata, e le libera. E’ il caso di Java, C#, Javascript, Python e la maggior parte delle applicazioni moderne. E’ una soluzione che quindi opera a runtime, ma che ha il difetto di rallentare l’esecuzione del programma.
  • compilatore intelligente: al termine di ogni funzione viene aggiunta dal compilatore una funzione che esegue una deallocazione automatica della memoria non più usabile. E’ il caso di Objective C o Swift (tramite un meccanismo chiamato ARC), e più di recente in linguaggi come Rust. Il sistema è molto efficiente e non rallenta l’applicazione.

Const e global

In C/C++ si possono dichiarare costanti con la parola chiave const:

int main() {
  const x = 3;
  ...
}

Una constante non è modificabile. Viene generata all’avvio del programma, e localizzata nella zona di memoria del codice (nello spazio delle variabili globali-statiche-costanti), quindi non occupa spazio nello stack.

E’ poi possibile definire simboli globali sia come variabili che come costanti:

int x;
const char = '0';

int main() {
  ...
}

Sono definiti fuori dalle altre funzioni e sono visibili ovunque nel programma. Sono memorizzate anch’esse nello spazio di memoria del codice.

Variabili statiche

Ogni volta che viene creata una variabile in una funzione, questa esisterà finché la funzione è in esecuzione, poi verrà distrutta. Lo stesso discorso vale per qualsiasi blocco rinchiuso da parentesi graffe { }.

Ad esempio nel codice seguente la variabile x viene ricreata ad ogni ciclo e distrutta al termine del ciclo.

#include <iostream>
using namespace std;

int main()
{
  for (int i=0; i<5; i++) {
    int x = 1; // x viene ricreata e distrutta 10 volte 
    x = x + i;
    cout << x << " "; // questo codice stamperà "1 2 3 4 5"
  }
  return 0;
}

Il discorso cambia se si utilizza il modificatore “static”. In questo caso la variabile una volta creata, continua ad esistere anche dopo che la funzione/blocco termina, e quindi la variabile mantiene il suo valore. Vediamo questo codice:

#include <iostream>
using namespace std;

int main()
{
  for (int i=0; i<5; i++) {
    static int x = 1; // x viene creata solo la prima volta
    x = x + i;
    cout << x << " "; // stampa "1 2 4 7 11"
  }
  return 0;
}

Vediamo in dettaglio la sequenza di valori che verrà stampata:

#cicloxix=x+i
1101
2112
3224
4437
57411

In pratica una volta definita una variabile static, rimane sempre in memoria, e quindi anche se ridichiarata in realtà continua ad usare il valore dell’ultima esecuzione. Quindi x avrà valore 1 al primo ciclo, ma dal ciclo successivo manterrà il valore precedentemente creato.

Facciamo però attenzione: x rimane in memoria, ma non rimane visibile all’esterno nel blocco/funzione dove è dichiarata.

Con static quindi una variabile si comporta come una variabile globale, ma mentre le variabili globali sono visibili ovunque nel programma, le statiche hanno una visibilità limitata.

Questo cosa significa concretamente?

  • Che quando viene definita una variabile static, se definita globalmente quella variabile sarà allocata staticamente in una posizione fissa della memoria del programma all’avvio dello stesso e sarà quindi visibile e modificabile in tutto il programma.
  • Quando viene definita dentro una funzione, sarà allocata alla prima esecuzione della funzione, ma manterrà il suo valore ad ogni nuova esecuzione della funzione, mantiene quindi anche questa una posizione fissa in memoria, ma sarà raggiungibile solo nel contesto in cui è dichiarata.
#include <iostream>
using namespace std;

int counter() {
  static int count = 0;
  count++;
  return count;
}
int main() {
  for (int i=0; i<5; i++) {
    cout << counter() << " "; // stampa "1 2 3 4 5"
  }
  return 0;
}
  • Se viene definita come static in un blocco, mantiene il valore precedente ad ogni nuova esecuzione di quel blocco, come visto nell’esempio sopra.

Il vantaggio dell’utilizzo di variabili statiche è di mantenere memoria di esecuzioni precedenti, cioè di memorizzare l’esecuzione di blocchi o funzioni precedenti.

Grazie alle variabili statiche è possibile creare funzioni che “si ricordano” di precedenti esecuzioni. Un esempio è proprio la funzione strtok.