30 aprile 2022

ArrayBuffer, array binari

Nello sviluppo web incontriamo dati di tipo binario principalmente quando lavoriamo con i file (creazione, upload o download). Un altro tipico caso d’uso è l’elaborazione di immagini.

Sono tutte cose possibili con JavaScript, e le operazioni binarie ottimizzano le prestazioni.

Sebbene ci sia un po’ di confusione, poiché esistono molte classi. Per citarne alcune:

  • ArrayBuffer, Uint8Array, DataView, Blob, File, etc.

I dati binari in JavaScript sono implementati in maniera non standard, rispetto ad altri linguaggi. Ma se riusciamo a riorganizzare le idee, tutto diventa piuttosto semplice.

L’oggetto binario di base è ArrayBuffer, un riferimento ad un’area di memoria contigua di lunghezza fissa.

Lo possiamo creare in questo modo:

let buffer = new ArrayBuffer(16); // creiamo un buffer di lunghezza 16
alert(buffer.byteLength); // 16

Questo alloca un’area di memoria contigua di 16 byte, e la popola con degli zeri.

ArrayBuffer è un array di

Eliminiamo subito una possibile fonte di confusione. ArrayBuffer non ha nulla in comune con Array:

  • Ha una dimensione prefissata, non possiamo aumentarla o diminuirla.
  • Occupa esattamente quello spazio in memoria.
  • Per accedere a uno specifico byte, è richiesto un ulteriore oggetto. buffer[index] non funzionerebbe.

ArrayBuffer rappresenta un’area di memoria. Cosa vi è memorizzano? Non ne ha assolutamente idea. Semplicemente una sequenza di byte.

Per manipolare un ArrayBuffer, dobbiamo utilizzare un oggetto “visualizzatore”.

Un oggetto visualizzatore non memorizza nulla. Funge da “lente di ingrandimento” che fornisce un interpretazione dei byte memorizzati in un ArrayBuffer.

Ad esempio:

  • Uint8Array: tratta ogni byte contenuto nell’ArrayBuffer come un numero separato, che può assumere valori compresi tra 0 e 255 (un byte è composta da 8-bit, quindi quell’intervallo è il massimo rappresentabile). Questo valore viene definito un “interno a 8-bit senza segno”.
  • Uint16Array: interpreta 2 byte come un intero, possono quindi assumere valori nell’intervallo da 0 a 65535. Questo viene definito un “intero a 16-bit senza segno”.
  • Uint32Array: interpreta 4 byte come un intero, possono quindi assumere valori nell’intervallo da 0 a 4294967295. Questo viene definito un “intero a 32-bit senza segno”.
  • Float64Array: interpreta 8 byte come un numero in virgola mobile, possono quindi assumere valori nell’intervallo da 5.0x10-324 a 1.8x10308.

Quindi, i dati binari contenuti in un ArrayBuffer di 16 byte possono essere interpretati con 16 “piccoli numeri”, oppure 8 “grandi numeri” (2 byte ciascuno), oppure 4 “numeri ancora più grandi” (4 byte ciascuno), oppure 2 valori in virgola mobile ad alta precisione (8 byte ciascuno).

ArrayBuffer è l’oggetto principale, la radice di tutto, quello che contiene i dati binari.

Ma nel momento in cui andiamo a scriverci qualcosa, o iteriamo su di esso, e per praticamente qualsiasi altra operazione, dobbiamo utilizzare un visualizzatore, e.g:

let buffer = new ArrayBuffer(16); // crea un buffer di lunghezza 16

let view = new Uint32Array(buffer); // interpreta un buffer come una sequenza di interi a 32 bit

alert(Uint32Array.BYTES_PER_ELEMENT); // intero a 4 byte

alert(view.length); // 4, numero di interi memorizzati
alert(view.byteLength); // 16, la dimensione in byte

// scriviamo un valore
view[0] = 123456;

// iteriamo sui valori
for(let num of view) {
  alert(num); // 123456, poi 0, 0, 0 (4 valori in totale)
}

TypedArray

Il termine comune utilizzato per i visualizzatori (Uint8Array, Uint32Array, etc) è TypedArray. Questi condividono lo stesso insieme di metodi e proprietà.

Da notare, non esiste alcun costruttore per il tipo TypedArray, è semplicemente un “ombrello” comune per rappresentare un visualizzatore per ArrayBuffer: Int8Array, Uint8Array e così via, vedremo la lista completa a breve.

Quando vedete qualcosa come new TypedArray, equivale a: new Int8Array, new Uint8Array, etc.

I TypedArray si comportano come gli array regolari: sono indicizzabili ed iterabili.

Un costruttore di TypedArray (che sia Int8Array o Float64Array, non ha importanza) si comporta diversamente in base al tipo degli argomenti.

Esistono 5 varianti degli argomenti:

new TypedArray(buffer, [byteOffset], [length]);
new TypedArray(object);
new TypedArray(typedArray);
new TypedArray(length);
new TypedArray();
  1. Se viene fornito un ArrayBuffer come argomento, il visualizzatore verrà creato su questo. Abbiamo già usato questa sintassi.

    Come parametri opzionali, possiamo fornire il byteOffset da cui iniziare (0 di default) e la length (fino alla fine del buffer di default), in questo modo il visualizzatore coprirà solamente una parte del buffer.

  2. Se viene fornito un Array, o un oggetto simil-array, viene creato un TypedArray delle stessa lunghezza e ne viene copiato il contenuto.

    Possiamo utilizzarlo per pre-popolare l’array con dei dati:

    let arr = new Uint8Array([0, 1, 2, 3]);
    alert( arr.length ); // 4, creazione di un array binario delle stessa lunghezza
    alert( arr[1] ); // 1, popolato con 4 byte (intero a 8-bit senza segno) con i valori forniti
  3. Se viene fornito un ulteriore TypedArray, accadrà la stessa cosa: verrà creato un TypedArray delle stessa lunghezza e ne verrà copiato il contenuto. I valori verranno convertiti al nuovo tipo, se necessario.

    let arr16 = new Uint16Array([1, 1000]);
    let arr8 = new Uint8Array(arr16);
    alert( arr8[0] ); // 1
    alert( arr8[1] ); // 232, prova a copiare 1000, ma non può essere rappresentato con 8 bit (vedi la spiegazione sotto)
  4. Per una argomento numerico length, verrà creato un TypedArray in grado di contenere quel numero di elementi. La sua dimensione in byte sarà length moltiplicato per il numero di byte per singolo elemento TypedArray.BYTES_PER_ELEMENT:

    let arr = new Uint16Array(4); //  crea un TypedArray per 4 interi
    alert( Uint16Array.BYTES_PER_ELEMENT ); // 2 byte per intero
    alert( arr.byteLength ); // 8 (dimensione in byte)
  5. Senza argomenti, crea un TypedArray di lunghezza zero.

Possiamo creare un TypedArray direttamente, senza menzionare ArrayBuffer. Ma un visualizzatore non può esistere senza un relativo ArrayBuffer, quindi questo verrà creato automaticamente in tutti i casi, ad eccezione del primo (in cui viene fornito).

Per accedere all’ArrayBuffer, abbiamo a disposizione le seguenti proprietà:

  • arr.buffer, che fa riferimento a ArrayBuffer.
  • arr.byteLength, la lunghezza dell’ArrayBuffer.

Quindi possiamo sempre cambiare da un visualizzatore ad un altro:

let arr8 = new Uint8Array([0, 1, 2, 3]);

// un altro visualizzatore per gli stessi dati
let arr16 = new Uint16Array(arr8.buffer);

Qui vediamo la lista dei TypedArray:

  • Uint8Array, Uint16Array, Uint32Array: per numeri interi a 8, 16 e 32 bit.
    • Uint8ClampedArray: per interi a 8-bit, “bloccati” in fase di assegnazione (vedi sotto).
  • Int8Array, Int16Array, Int32Array: per numeri interi con segno (possono essere negativi).
  • Float32Array, Float64Array: per numeri in virgola mobile con segno, a 32 e 64 bit.
Non esistono int8, o simili, tipi a singolo valore

Da notare che, nonostante i nomi, come Int8Array, non esiste alcun tipo a singolo valore come int, o int8 in JavaScript.

E’ abbastanza logico, poiché Int8Array non è un array per questi valori, ma piuttosto un visualizzatore su un ArrayBuffer.

Comportamento per valori fuori dai limiti

Cosa accade se proviamo a scrivere un valore fuori dai limiti in un TypedArray? Non verrà generata alcuna eccezione. Ma i bit di troppo verranno ignorati.

Ad esempio, proviamo a scrivere 256 in Uint8Array. Nella forma binaria, 256 equivale a 100000000 (9 bit), ma Uint8Array fornisce solamente 8 bit per valore, questo fa si che l’intervallo di valori disponibili vada da 0 a 255.

Per numeri più grandi, vengono memorizzati solamente gli 8 bit più a destra (meno significativi), gli altri verranno ignorati:

Quindi otterremo uno zero.

Nel caso di 257, la forma binaria è 100000001 (9 bit), vengono memorizzati gli 8 bit più a destra, quindi il valore memorizzato nell’array sarà 1: