23 octobre 2022

ArrayBuffer, tableaux binaires

Dans le dĂ©veloppement web, nous rencontrons des donnĂ©es binaires principalement lorsque l’on travaille avec des fichiers (crĂ©ation, envoi, tĂ©lĂ©chargement). Un autre cas d’utilisation est le traitement d’image.

Tout ceci est possible en JavaScript, et les opérations binaires sont trÚs performantes.

Cependant, il y a de la confusion, car il y a beaucoup de classes disponibles. Pour en nommer quelques unes:

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

En javascript, les donnĂ©es binaires sont implĂ©mentĂ©es de façon non standard, comparĂ© Ă  d’autres langages. Mais quand nous mettons de l’ordre dans tout ça, tout devient beaucoup plus simple.

L’objet binaire de base est un ArrayBuffer – une rĂ©fĂ©rence Ă  une zone contigĂŒe de taille fixe de la mĂ©moire.

Nous le créons comme ceci:

let buffer = new ArrayBuffer(16); // crée un Buffer de taille 16
alert(buffer.byteLength); // 16

Cela alloue une zone contigue de 16 octets dans la mémoire et la pré-remplie avec des zéros.

L’ArrayBuffer n’est pas un tableau de ‘quelque chose’.

Commençons par Ă©liminer une possible source de confusion. ArrayBuffer n’a rien en commun avec Array:

  • Il possĂšde une taille fixe, nous ne pouvons ni l’aggrandir, ni le rĂ©duire.
  • Il prend une taille spĂ©cifique en mĂ©moire.
  • Pour accĂ©der Ă  des octets individuels, un autre objet de “vue” est nĂ©cessaire, on n’utilise pas buffer[index].

ArrayBuffer est une zone de la mĂ©moire. Qui y’a t’il Ă  l’intĂ©rieur ? Juste une sĂ©quence d’octets.

Pour manipuler un ArrayBuffer, nous avons besoin d’utiliser un objet de “vue”.

Un objet de “vue” ne stocke rien tout seul. Ce sont les lunettes qui donnent une interprĂ©tation des octets stockĂ©s dans l’ArrayBuffer.

Par exemple:

  • Uint8Array – Traite chaque octet dans l’ArrayBuffer comme un nombre unique, avec des valeurs possibles entre 0 jusqu’à 255 (Un octet est sur 8 bits, donc ça ne peut contenir que ça). On appelle ces valeurs des “entiers non signĂ©s sur 8 bits”.
  • Uint16Array – Traite par paquet de 2 octets en tant qu’entier, avec des valeurs possibles entre 0 jusqu’à 65535. On appelle ces valeurs des “entiers non signĂ©s sur 16 bits”.
  • Uint32Array – Traite par paquet de 4 octets en tant qu’entier, avec des valeurs possibles entre 0 jusqu’à 4294967295. On appelle ces valeurs des “entiers non signĂ©s sur 32bits”.
  • Float64Array – Traite par paquet de 8 octets en tant que nombre flottant avec des valeurs possibles entre 5.0x10-324 et 1.8x10308.

Donc, les donnĂ©es binaires dans un ArrayBuffer de 16 octets peuvent ĂȘtre interprĂ©tĂ©es comme 16 “petits nombres” , ou 8 grands nombres (2 octets chacun), ou 4 encore plus grands (4 octets chacun), ou 2 valeurs flottantes avec une haute prĂ©cision (8 octets chacun).

ArrayBuffer est l’objet central, le centre de tout, les donnĂ©es binaires brutes.

Mais si nous voulons Ă©crire Ă  l’intĂ©rieur, ou itĂ©rer dessus, pour n’importe quelle opĂ©ration – nous devons utiliser une “vue”, e.g:

let buffer = new ArrayBuffer(16); // crée un buffer de taille 16

let view = new Uint32Array(buffer); // Traite le buffer en une séquence d'entiers de 32 bits.

alert(Uint32Array.BYTES_PER_ELEMENT); // 4 octets par entier.

alert(view.length); // 4, il stocke cette quantité d'entiers.
alert(view.byteLength); // 16, la taille en octets.

// Ecrivons une valeur
view[0] = 123456;

// Itérons sur les valeurs
for(let num of view) {
  alert(num); // 123456, puis 0, 0, 0 (4 valeurs au total)
}

TypedArray – tableau typĂ©

Le terme commun pour toutes ces vues (Uint8Array, Uint32Array, etc) est TypedArray. Elles partagent le mĂȘme ensemble de mĂ©thodes et de propriĂ©tĂ©s.

Il faut noter qu’il n’y a pas de construteur appelĂ© TypedArray, Il s’agit d’un terme pour reprĂ©senter une des vues par dessus un ArrayBuffer: Int8Array, Uint8Array etc. La liste entiĂšre va bientĂŽt suivre.

Lorsque vous voyez quelque chose comme new TypedArray, Il s’agit de n’importe quoi parmi new Int8Array, new Uint8Array, etc.

Les tableaux typés ressemblent à des tableaux classiques : ils ont des indexs et sont itérables.

Un constructeur TypedArray (soit Int8Array ou Float64Array, peut importe) se comporte différement en fonction du type des arguments.

Il y a 5 variantes d’arguments:

new TypedArray(buffer, [byteOffset], [length]);
new TypedArray(object);
new TypedArray(typedArray);
new TypedArray(length);
new TypedArray();
  1. Si un ArrayBuffer est fourni, la vue est créée dessus. Nous avons déjà utilisé cette syntaxe.

    Nous pouvons Ă©ventuellement fournir un dĂ©calage (byteOffset) pour commencer Ă  partir de lĂ  (0 par dĂ©faut) et la longueur (length) (jusqu’à la fin du buffer par dĂ©faut), alors la vue ne va couvrir qu’une partie du buffer.

  2. Si c’est un Array, ou quelque chose ressemblant Ă  un tableau qui est fourni, il crĂ©e un tableau typĂ© de la mĂȘme longueur et copie le contenu.

    Nous pouvons l’utiliser pour prĂ©-remplir le tableau avec les donnĂ©es:

    let arr = new Uint8Array([0, 1, 2, 3]);
    alert( arr.length ); // 4, a créé une liste binaire de la mĂȘme taille
    alert( arr[1] ); // 1, remplit avec 4 octets (entiers non signés sur 8 bits) avec des valeurs données
  3. Si un autre tableau typĂ© est fourni, il fait la mĂȘme chose: il crĂ©e un tableau typĂ© de la mĂȘme taille et copie le contenu. Les valeurs sont converties vers le nouveau type dans le processus si besoin.

    let arr16 = new Uint16Array([1, 1000]);
    let arr8 = new Uint8Array(arr16);
    alert( arr8[0] ); // 1
    alert( arr8[1] ); // 232, 1000 ne rentre pas dans 8 bits (explications plus loin)
  4. Si un argument length est fourni – Il crĂ©e un tableau typĂ© qui contient autant d’élĂ©ments. Sa taille en octets va ĂȘtre length multipliĂ© par la taille en octets d’un seul Ă©lĂ©ment TypedArray.BYTES_PER_ELEMENT:

    let arr = new Uint16Array(4); // Création d'un tableau typé de 4 entiers
    alert( Uint16Array.BYTES_PER_ELEMENT ); // 2 octets par entier
    alert( arr.byteLength ); // 8 (taille en octets)
  5. Sans arguments, il crée un tableau typé de taille nulle.

Nous pouvons créer un tableau typé directement sans fournir un ArrayBuffer. Mais une vue ne peut pas exister sans, donc il sera créé automatiquement dans tous les cas, sauf le premier (quand il est passé en argument).

Pour accéder au ArrayBuffer sous-jacent, il existe les propriétés suivantes dans TypedArray :

  • buffer – qui fait rĂ©fĂ©rence Ă  l’ArrayBuffer.
  • byteLength – qui correspond Ă  la taille de l’ArrayBuffer.

Donc nous pouvons toujours passer d’une vue à l’autre:

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

// Une autre vue avec les mĂȘmes donnĂ©es
let arr16 = new Uint16Array(arr8.buffer);

Voici une liste de tableaux typés:

  • Uint8Array, Uint16Array, Uint32Array – Pour les entiers de 8, 16 et 32 bits.
    • Uint8ClampedArray – Pour les entiers de 8 bits, avec une “restriction” Ă  l’affectation (voir plus loin).
  • Int8Array, Int16Array, Int32Array – Pour les nombres entiers signĂ©s (peuvent ĂȘtre nĂ©gatifs).
  • Float32Array, Float64Array – Pour les nombres flottants signĂ©s de 32 et 64 bits.
Pas de int8 ou de types similaires

MalgrĂ© la prĂ©sence de noms tels que Int8Array, il n’y a pas de type comme int ou int8 dans JavaScript.

Car en effet Int8Array n’est pas un tableau de ces valeurs individuelles, mais plutît une vue sur ArrayBuffer.

Comportement hors limite

Que se passe t’il lorsque nous essayons d’écrire des valeurs en dehors des limites dans un tableau typĂ© ? Il n’y aura pas d’erreurs, mais les bits en trop seront supprimĂ©s.

Par exemple, essayons d’ajouter 256 dans un Uint8Array. En binaire, 256 s’écrit 100000000 (9 bits), mais un Uint8Array ne permet que 8 bits par valeur, ce qui donne des valeurs possibles entre 0 et 255.

Pour les grands nombres, seuls les 8 bits les plus à droite (moins significatif) sont sauvegardés, et le reste est supprimé:

Donc nous allons obtenir 0.

Pour 257, l’écriture binaire est 100000001 (9 bits), les 8 bits les plus Ă  droite sont gardĂ©s, donc on aura un 1 dans notre tableau:

En d’autres termes, Le nombre modulo 28 est sauvegardĂ©.

Démonstration:

let uint8array = new Uint8Array(16);

let num = 256;
alert(num.toString(2)); // 100000000 (représentation binaire)

uint8array[0] = 256;
uint8array[1] = 257;

alert(uint8array[0]); // 0
alert(uint8array[1]); // 1

Uint8ClampedArray possĂšde un comportement diffĂ©rent. Il garde 255 pour n’importe quel nombre qui est plus grand que 255, et 0 pour n’importe quel nombre nĂ©gatif. Ce comportement est utile dans le traitement d’images.

Méthodes des tableaux typés

TypedArray possÚde les méthodes de Array, avec quelques exceptions notables.

Nous pouvons itérer, map, slice, find, reduce etc.

Mais certaines choses ne sont pas possibles:

  • Pas de splice – On ne peut pas supprimer une valeur, car les tableaux typĂ©s sont des vues sur un buffer, qui sont des zones fixes dans la mĂ©moire. Tout ce que nous pouvons faire est de mettre un 0.
  • Pas de mĂ©thode concat.

Il y a deux méthodes supplémentaires:

  • arr.set(fromArr, [offset]) copie tous les Ă©lĂ©ments de fromArr vers arr, en commençant Ă  partir de la position offset (0 par dĂ©faut).
  • arr.subarray([begin, end]) crĂ©e une nouvelle vue du mĂȘme type de begin jusqu’à end (non-inclus). C’est similaire Ă  la mĂ©thode slice (qui est Ă©galement disponible), mais elle ne copie rien – il s’agit juste d’une crĂ©ation d’une nouvelle vue, pour travailler sur un certain morceau de donnĂ©es.

Les mĂ©thodes nous permettent de copier des tableaux typĂ©s, de les mĂ©langer, de crĂ©er des nouveaux tableaux depuis ceux existants, et bien d’autres choses.

DataView

DataView est une vue spĂ©ciale “non typĂ©e” super flexible sur Ê»ArrayBuffer`. Il permet d’accĂ©der aux donnĂ©es sur n’importe quel offset dans tous les formats.

  • Pour les tableaux typĂ©s, le constructeur dĂ©termine le format. Le tableau entier est supposĂ© ĂȘtre uniforme. Le i-Ăšme nombre est notĂ© arr[i].
  • Avec DataView nous accĂ©dons aux donnĂ©es avec des mĂ©thodes comme .getUint8(i) ou .getUint16(i). Nous choisissons le format au moment de l’utilisation de la mĂ©thode au lieu du moment de la crĂ©ation.

Voici la syntaxe:

new DataView(buffer, [byteOffset], [byteLength])
  • buffer – ArrayBuffer. Contrairement aux tableaux typĂ©s, DataView ne crĂ©e pas soit mĂȘme un buffer. Nous avons besoin de le lui fournir directement.
  • byteOffset – L’octet de dĂ©part de la vue (par dĂ©faut Ă  0).
  • byteLength – La taille totale de la vue en octets (par dĂ©faut jusqu’à la fin de buffer).

Pour l’exemple, nous allons rĂ©cupĂ©rer des nombres dans plusieurs formats avec le mĂȘme buffer:

// Tableau binaire de 4 octets, tous ayant la valeur maximale - 255
let buffer = new Uint8Array([255, 255, 255, 255]).buffer;

let dataView = new DataView(buffer);

// récupération d'un nombre en 8 bits avec un décalage de 0
alert( dataView.getUint8(0) ); // 255

// récupération d'un nombre en 16 bits avec un décalage de 0, soit 2 octets, qui sont interprétés ensemble en 65535
alert( dataView.getUint16(0) ); // 65535 (Plus grand entier non signé en 16 bits)

// récupération d'un nombre en 32 bits avec un décalage de 0
alert( dataView.getUint32(0) ); // 4294967295 (Plus grand entier non signé en 32 bits)

dataView.setUint32(0, 0); // Fixe le nombre sous 4 octets Ă  0, fixant ainsi tous les octets Ă  0

DataView est utile lorsque l’on met des donnĂ©es sous plusieurs formats dans le mĂȘme buffer. Par exemple, on stocke une sĂ©quence de paires (16-bit integer, 32-bit float). DataView nous permettra d’y accĂ©der facilement.

Résumé

ArrayBuffer est l’objet au coeur de tout, c’est une rĂ©fĂ©rence Ă  une zone de taille fixe dans la mĂ©moire.

Pour faire presque n’importe quelle opĂ©ration sur un ArrayBuffer, nous avons besoin d’une vue.

  • Il peut s’agir d’un tableau typĂ©:
    • Uint8Array, Uint16Array, Uint32Array – pour les entiers non-signĂ©s de 8, 16, et 32 bits.
    • Uint8ClampedArray – pour les entiers de 8 bits, “clamps” them on assignment.
    • Int8Array, Int16Array, Int32Array – pour les entiers signĂ©s (peuvent ĂȘtre nĂ©gatifs).
    • Float32Array, Float64Array – pour les nombres flottants signĂ©s de 32 et 64 bits.
  • Ou d’un DataView – la vue qui utilise des mĂ©thodes pour spĂ©cifier un format, e.g. getUint8(offset).

Dans la majorité des cas, on crée et on opÚre directement sur les tableaux typés, laissant ArrayBuffer en arriÚre. On peut toujours y accéder avec .buffer et faire une nouvelle vue si besoin.

Il y a également 2 termes supplémentaires, qui sont utilisés dans les descriptions des méthodes pour travailler sur les données binaires:

  • ArrayBufferView qui est le terme pour tous les types de vues.
  • BufferSource qui est un terme dĂ©signant soit un ArrayBuffer ou un ArrayBufferView.

Nous verrons ces termes dans les prochains chapitres. BufferSource est l’un des termes les plus communs, qui veut dire “toutes sortes de donnĂ©es binaires” – un ArrayBuffer ou une vue par dessus.

Voici un cheatsheet :

Exercices

En passant un tableau de Uint8Array, écrivez une fonction concat(arrays) qui retourne une concaténation dans un seul tableau.

Open a sandbox with tests.

function concat(arrays) {
  // sum of individual array lengths
  let totalLength = arrays.reduce((acc, value) => acc + value.length, 0);

  let result = new Uint8Array(totalLength);

  if (!arrays.length) return result;

  // for each array - copy it over result
  // next array is copied right after the previous one
  let length = 0;
  for(let array of arrays) {
    result.set(array, length);
    length += array.length;
  }

  return result;
}

Ouvrez la solution avec des tests dans une sandbox.

Carte du tutoriel

Commentaires

lire ceci avant de commenter

  • Si vous avez des amĂ©liorations Ă  suggĂ©rer, merci de soumettre une issue GitHub ou une pull request au lieu de commenter.
  • Si vous ne comprenez pas quelque chose dans l'article, merci de prĂ©ciser.
  • Pour insĂ©rer quelques bouts de code, utilisez la balise <code>, pour plusieurs lignes – enveloppez-les avec la balise <pre>, pour plus de 10 lignes - utilisez une sandbox (plnkr, jsbin, codepen
)