Nei titoli e nei testi troverete qualche rimando cinematografico (ebbene si, sono un cinefilo). Se non vi interessano fate finta di non vederli, già che non sono fondamentali per la comprensione dei post...

Di questo blog ho mandato avanti, fino a Settembre 2018, anche una versione in Spagnolo. Potete trovarla su El arte de la programación en C. Buona lettura.

lunedì 24 marzo 2025

Il buono, il brutto, il VLA
come usare i Variable Length Arrays in C - pt.1

cacciatore di taglie: Ehi, lo sai che la tua faccia somiglia a quella di uno che vale duemila dollari?
Biondo (il buono): [comparendo alle loro spalle] Già... ma tu non somigli a quello che li incassa...

(...una premessa: questo post è un remake di un mio vecchio post (parte 1 di 3). Ma, anche se tratta lo stesso argomento, amplia e perfeziona un po' il discorso è mi è sembrato il caso di riproporlo. Leggete e mi direte...)

Il riferimento cinematografico di questo mese calza proprio a pennello: un Variable Length Array (VLA per gli amici) sarebbe perfetto per fare la parte del cattivo nel capolavoro Il buono, il brutto, il cattivo del grande Sergio Leone. E alla fine del (prossimo) articolo sarà chiaro il perché.

...ciao sono un VLA: inizia a preoccuparti...

I  VLA sono una cosa relativamente (si, molto relativamente) nuova del C: sono stati introdotti nel C99, e sono, apparentemente, il sogno fatto realtà del mondo C: "Finalmente gli array con dimensione variabile! Ah, se li avessi avuti prima del '99!". Allora: l'idea è semplice, con un VLA potete scrivere cosucce tipo queste:

void myVla(
int size1, // un size desiderato del VLA
int size2) // un size desiderato del VLA
{
// il mio VLA di int
int ivla[size1];

// fai qualcosa con il VLA di int
...

// il mio VLA bidimensionale di float
float fvla[size1][size2]:

// fai qualcosa con il VLA bidimensionale di float
...
}

Fantastico, no? Troppo bello per essere vero... ma ci saranno delle controindicazioni? Sicuramente non nelle prestazioni: ho scritto giustappunto un po' di codice per testare le prestazioni dei VLA rispetto alle alternative più immediate: array dinamici (con malloc(3)) e array fissi (in heap e stack). Vediamolo, no? Vai col codice!

#include <stdio.h>
#include <time.h>
#include <stdlib.h>

#define MYSIZE 1000000

// variabile dummy per evitare lo svuotamento totale delle funzioni usando GCC -O2
int avoid_optimization;

// prototipi locali
void runTest(int iterations, void (*funcptr)(int), int size, const char *name);
void testVLA(int size);
void testMallocVLA(int size);
void testStackFLA(int dum);
void testHeapFLA(int dum);

// funzione main()
int main(int argc, char* argv[])
{
// test argomenti
if (argc != 2) {
// errore: conteggio argomenti errato
printf("%s: wrong arguments counts\n", argv[0]);
printf("usage: %s vla iterations [e.g.: %s 10000]\n", argv[0], argv[0]);
return EXIT_FAILURE;
}

// estrae iterazioni
int iterations = atoi(argv[1]);

// esegue test
runTest(iterations, &testVLA, MYSIZE, "testVLA");
runTest(iterations, &testMallocVLA, MYSIZE, "testMallocVLA");
runTest(iterations, &testStackFLA, 0, "testStackFLA");
runTest(iterations, &testHeapFLA, 0, "testHeapFLA");

// esce
return EXIT_SUCCESS;
}

// runTest() - funzione per eseguire i test
void runTest(
int iterations, // iterazioni del test
void (*funcptr)(int), // funzione di test
int size, // size dell'array
const char *name) // nome funzione di test
{
// prende start time
clock_t t_start = clock();

// esegue iterazioni test
for (int i = 0; i < iterations; i++)
(*funcptr)(size);

// prende end time e mostra il risultato
clock_t t_end = clock();
double t_passed = ((double)(t_end - t_start)) / CLOCKS_PER_SEC;
printf("%-13s - Tempo trascorso: %f secondi\n", name, t_passed);
}

// testVLA() - funzione per eseguire il test del VLA
void testVLA(
int size) // size per VLA
{
int vla[size];

// loop di test
for (int i = 0; i < size; i++)
vla[i] = i;

// istruzione per evitare lo svuotamento totale della funzione usando GCC -O2
avoid_optimization = vla[size / 2];
}

// testMallocVLA() - funzione per eseguire il test del malloc VLA
void testMallocVLA(
int size) // size per malloc()
{
int *mallocvla = malloc(size * sizeof(int));

// loop di test
for (int i = 0; i < size; i++)
mallocvla[i] = i;

// istruzione per evitare lo svuotamento totale della funzione usando GCC -O2
avoid_optimization = mallocvla[size / 2];

free(mallocvla);
}

// testStackFLA() - funzione per eseguire il test dello stack FLA
void testStackFLA(
int dum) // parametro dummy
{
int stackfla[MYSIZE];

// loop di test
for (int i = 0; i < MYSIZE; i++)
stackfla[i] = i;

// istruzione per evitare lo svuotamento totale della funzione usando GCC -O2
avoid_optimization = stackfla[MYSIZE / 2];
}

// testHeapFLA() - funzione per eseguire il test dello heap FLA
int heapfla[MYSIZE];
void testHeapFLA(
int dum) // parametro dummy
{
// loop di test
for (int i = 0; i < MYSIZE; i++)
heapfla[i] = i;
}

Ok, come vedete è ampiamente commentato e quindi è auto-esplicativo, per cui non mi dilungherò sulle singole istruzioni e/o gruppi di istruzioni (leggete i commenti! sono li per quello!), ma aggiungerò, solo, qualche dettaglio strutturale.

Allora: visto che si tratta di un test comparativo ho scritto una funzione runTest() che chiama n-iterazioni della funzione da testare e conta il tempo impiegato. Il main() si limita a chiamare quattro volte runTest(), una per ogni funzione. Le quattro funzioni di test che ho scritto testano (come richiamato dai nomi, ovviamente): un C99-VLA (la variabile vla), un tradizionale malloc-VLA (la variabile mallocvla), un FLA (Fixed Lengh Array) allocato nello stack (la variabile stackfla) e un FLA allocato nello heap (la variabile heapfla). Per ogni test viene usato un (gran) array-size di 1000000 e il numero di iterazioni si decide al lancio dell'applicazione (questo è molto utile come vedremo tra poco). Ovviamente il malloc-VLA l'ho chiamato così non perché sia un vero e proprio VLA, ma perché rappresenta il modo tradizionale di creare a run-time un array con size "dinamico".

Notare che runTest() usa un function pointer per lanciare il test (avevamo visto qualcosa del genere parlando qui delle callback): ho usato la versione estesa della dichiarazione (void (*funcptr)(int) + passaggio della funzione con l'operatore &) ma vi ricordo che, ad esempio, GCC digerisce facilmente anche la dichiarazione semplificata (void funcptr(int) + passaggio senza l'operatore &). La versione estesa è, ovviamente, più portabile. E visto che siamo in tema di compilatori: anche se i VLA sono ammessi solo da C99 in avanti non c'è bisogno (se usate GCC) di specificare il flag -std=c99 in compilazione: siamo nel 2025 (come passa il tempo!) e le versioni recenti di GCC includono di default (come minimo) anche il C99 (oltre alle estensioni del GNU C).

E, già che ci siamo, facciamo un accenno sul discorso "compatibilità & retrocompatbilità": se proprio volete essere sicuri che quello che avete scritto rispetta uno standard in particolare dovete usare correttamente i flag di compilazione: ad esempio, se volete scrivere usando solo il C89, dovete aggiungere sulla linea di compilazione: -std=c89 -pedantic. Se poi state usando un GCC veramente datato allora la compilazione dell'esempio con i VLA vi darà Warning e/o errori, e dovrete ricompilare forzando (se possibile) la compatibilità col C99.

Notare anche che ho aggiunto, in ogni funzione di test, una semplice istruzione per usare l'array creato (é questa: avoid_optimization = ...), per evitare che, compilando con -O2, l'ottimizzatore del GCC azzeri il contenuto della funzione stessa: infatti, se l'array non lo usa nessuno, il nostro amico GCC si prende la libertà di eliminare (praticamente) la funzione, con il risultato che il test passa in 0 secondi!

E vediamo i risultati!

aldo@Linux $ gcc -O0 vla.c -o vla
aldo@Linux $ ./vla 2000
testVLA - Tempo trascorso: 3.918936 secondi
testMallocVLA - Tempo trascorso: 2.729077 secondi
testStackFLA - Tempo trascorso: 3.648311 secondi
testHeapFLA - Tempo trascorso: 3.623842 secondi

aldo@Linux $ gcc -O2 vla.c -o vla
aldo@Linux $ ./vla 2000
testVLA - Tempo trascorso: 0.664499 secondi
testMallocVLA - Tempo trascorso: 0.616732 secondi
testStackFLA - Tempo trascorso: 0.211779 secondi
testHeapFLA - Tempo trascorso: 0.258773 secondi

Come vedete ho eseguito test senza ottimizzazione (con il flag di compilazione -O0 che si può anche omettere visto che è il default) e con ottimizzazione (con il flag di compilazione -O2 ) e, ovviamente, mi è tornato utile il parametro n-iterazioni dell'applicazione, perché mi ha permesso di trovare un valore adatto a ottenere risultati significativi pur evitando tempi di esecuzione biblici per la versione senza ottimizzazioni. Come possiamo commentare? Beh, il VLA se la cava egregiamente, con e senza ottimizzazioni! Ottiene, praticamente, gli stessi risultati del suo diretto concorrente, il malloc-VLA, ed è più semplice da usare!

E allora, tornando sul pezzo: si può dire che il VLA è approvato!

MA PERÒ...

Beh, il però del VLA "cattivo" anticipato sopra ve lo spiegherò meglio nel prossimo articolo, e sappiate che non è tutto oro quello che luccica... e tanto per farvi un piccolo spoiler sulle prossime considerazioni finali: io non uso mai i VLA nel codice che scrivo!

Ciao e al prossimo post!