Seleccionar página

Hasta hace relativamente poco, en Javascript era complicado gestionar datos binarios, ya que entre los tipos de datos primitivos no se encontraban ningún valor binario que pudiéramos utilizar de forma sencilla. Desde hace algún tiempo se ha extendido en las diferentes implementaciones de Javascript (posteriormente han sido estandarizados con ES6) los ArrayBuffer, DataView y las matrices con tipo (Typed Array), que ponen a nuestra disposición un conjunto bastante completo de herramientas para manejar tipos binarios sin problemas.

Cada día son más las APIs y funcionalidades, tanto en el browser como en Node, que manejan datos binarios. Antes o después vamos a encontrarnos con la necesidad de utilizar ArrayBuffer, DataView y las diferentes matrices con tipo (Typed Array) si utilizamos APIs como:

  • FileAPI
  • XMLHttpRequest
  • Fetch
  • CryptoAPI
  • WebGL
  • Canvas 2D
  • WebSocket binarios

No podemos ignorar cómo se debe trabajar con soltura con datos binarios y por ello vamos a dar un repaso general a su funcionamiento. Posteriormente, veremos cómo se pueden comparar dos variables que contengan datos binarios para comprobar si son equivalentes y cómo integrar esta comparación en la función equal() que venimos explicando en esta serie de artículos.

ArrayBuffer

El primer elemento que debemos comprender ArrayBuffer. Aunque se denomine Array, realmente es un área de memoria reservada donde vamos a almacenar un conjunto de datos binarios. Esta área es de tamaño fijo y no podremos hacerla crecer o reducir dinámicamente.

No vamos a poder trabajar directamente con esta área, ya que no existen métodos o funciones que sean capaces de acceder directamente al ArrayBuffer, por lo que tendremos que utilizar una matriz con tipo (Type Array) o un DataView para acceder al contenido de una ArrayBuffer. Esta característica es bastante desconcertante. En Javascript estamos acostumbrados a manejar datos directamente y el hecho de que exista un tipo de datos que no podemos manipular sin la intervención de otros elementos del lenguaje resulta bastante sorprendente.

Podemos crear un ArrayBuffer con un sencillo:

var ab = new ArrayBuffer(100);

La interfaz de los objetos del tipo ArrayBuffer es muy escasa, sólo tienen los siguientes miembros:

  • byteLength: indica el tamaño en bytes del objeto.
  • slice(init [, end]): devuelve un nuevo ArrayBuffer con parte del contenido original.

Como hemos comentado, cada día más APIs devuelven valores de tipo ArrayBuffer. Por ejemplo, si utilizamos el API Fetch para hacer llamadas http obtendremos un objeto de este tipo si utilizamos response.arrayBuffer().

Si leemos un fichero con FileAPI podemos obtener el contenido binario del fichero por medio del método file.readAsArrayBuffer(). Puedes ver como funciona en este ejemplo del uso de FileAPI con ficheros binarios.

En todos los casos el ArrayBuffer debemos complementarlo con una matriz con tipo o un DataView para poder acceder a su contenido. Vamos a ver cómo.

Matrices con tipo

Aunque a primera vista podamos creer que una “matriz con tipo” es una matriz de un solo tipo de valores, como una matriz que sólo acepta de cadenas. Lo cierto es que estas matrices son una forma de acceso a los datos binarios almacenados en un ArrayBuffer con una interfaz similar a la de las matrices. El “tipo” se refiere a una “vista” concreta de ese buffer como enteros sin signo de 8 bits, enteros de 32 bits, coma flotante de 64 bits, etc.

Disponemos de las siguientes matrices con tipos:

MatrizBytes por elementoDescripción
Int8Array1entero de 8-bit con signo
Uint8Array1entero de 8-bit sin signo
Uint8ClampedArray1entero de 8-bit sin signo restrigido a valores entre 0 y 255
Int16Array2entero de 16-bit con signo
Uint16Array2entero de 16-bit sin signo
Int32Array4entero de 32-bit con signo
Uint32Array4entero de 32-bit sin signo
Float32Array4Coma flotante de 32-bit
Float64Array8 Coma flotante de 64-bit

Para comprender mejor cómo funcionan, vamos a ver cómo sobre un mismo ArrayBuffer podemos establecer uno, dos o más matrices con tipo. Cada una de las matrices trabajará sobre el mismo buffer, y cada uno de ellos tratará los datos según el tipo de datos que tiene definido. En este ejemplo creamos un ArrayBuffer de 2 bytes y creamos dos matrices con tipo sobre el mismo buffer, una de enteros de 8 bits sin signo y otra de enteros de 16 bits sin signo:

var buffer = new ArrayBuffer(2);

var aUint16 = new Uint16Array(buffer);
var aUint8 = new Uint8Array(buffer);

aUint16[0] = 1000;
console.log('aUint16[0] =', aUint16[0]);
console.log('aUint8[0] =', aUint8[0]);
console.log('aUint8[1] =', aUint8[1]);

Como se puede ver, al escribir en la matriz de enteros de 16 bits, estamos escribiendo en el buffer y la matriz de enteros de 8 bits devuelve los valores cambiados. Lo que ha pasado es que escribimos en el buffer que comparten ambas matrices.

Orden de bits

Es muy importante entender cómo se organizan los bits cuando trabajamos con valores que ocupan más de un byte, ya que existen dos formas de organizar los valores (big-endian y little-endian), lo que puede llegar a ser un problema si los datos binarios se crean con un orden de bits y son interpretados sobre una plataforma con el orden de bits opuesto.

En el ejemplo anterior hemos asignado el valor decimal 1000, cuya representación binaria es 0000001111101000. En una plataforma little endian se almacena en el buffer un conjunto de bits de este tipo 11101000 y 00000011, de esta forma cuando accedemos con la matriz de enteros de 8 bits sin signo, en la primera posición obtenemos un valor entero 232 que corresponde a 11101000 y en la segunda posición un entero 3 que corresponde a 00000011. Si lo consultáramos en una plataforma big endian el resultado sería el opuesto.

Todo esto no nos debe preocupar mucho si trabajamos con datos generados en nuestro propio sistema o si conocemos el origen de los datos binarios que recibimos, pero no podemos asumir que todos los sistemas se van a comportar de la misma forma, por lo que si trabajamos con datos binarios en varias plataformas deberemos tener en cuenta si son little endian o big endian.

Trabajando con matrices con tipo

Además de poder construir una matriz con tipo desde un ArrayBuffer, también podemos utilizar el constructor de este tipo de matrices con otros valores iniciales:

  • Tamaño de la matriz.
  • Objeto array-like o iterable que se convertirá como contenido de la nueva matriz.
  • Otra matriz tipada que se copiará como contenido de la nueva matriz.
  • En el caso de los ArrayBuffer podemos indicar el inicio y fin del buffer desde el que operará la matriz con tipo.

La interfaz de los objetos de matriz con tipo es similar a las matrices y podemos trabajar con ella sin dificultad. Sólo tenemos que tener en cuenta es una matriz de tamaño fijo y por lo tanto no se pueden borrar o añadir elementos de la matriz y no pueden producirse matrices dispersas. Por lo tanto, no están disponibles estos los métodos:

  • .concat()
  • .pop()
  • .push()
  • .shift()
  • .splice()
  • .unshift()

Por otra parte, las matrices con tipo, además de todos los demás métodos y propiedades de las matrices, disponen de los siguientes miembros adicionales:

  • .buffer: devuelve el ArrayBuffer asociado a la matriz.
  • .byteLength: devuelve la longitud (en bytes) de la matriz.
  • .byteOffset: devuelve el desplazamiento (en bytes) de los valores del ArrayBuffer.
  • .set(): almacena múltiples valores en el ArrayBuffer.
  • .subarray(): Devuelve una nueva matriz con tipo TypedArray desde el inicio y fin indicado.

En todo el resto de aspectos, se comporta exactamente igual que una matriz, sus elementos se acceden por índice, tenemos todos los métodos (excepto los señalados más arriba) y podemos llamar a .forEach(), .map(), etc.

DataView

Como comentamos al inicio, el otro mecanismo para trabajar con el contenido de un ArrayBuffer es utilizar un objeto de tipo DataView. Este tipo de objeto es similar a las matrices con tipo, en cuanto que permiten modificar el contenido de buffer, pero ofrece mayor flexibilidad a la hora de trabajar con diferentes tipos de datos.

Para crear una DataView debemos crear un nuevo objeto y pasar al constructor el ArrayBuffer que queremos utilizar.

var buffer = new ArrayBuffer(2);
var dataview = new DataView(buffer);

Además del ArrayBuffer, al constructor le podemos indicar el inicio y fin desde el que operará la vista sobre el buffer, igual que podíamos hacer con las matrices con tipo.

La interfaz que nos ofrece DataView permite escribir y leer cualquier tipo de dato en cualquier posición y no requiere que prefijemos por adelantado los tipos que vamos a manejar. Además, permite definir si los datos que ocupan más de un byte será almacenados en modo big-endian y little-endian, independientemente del entorno en el que nos encontremos, lo cual nos da un gran control.

var buffer = new ArrayBuffer(2);
var dataview = new DataView(buffer);

dataview.setUint16(0, 1000, true);
console.log('dataview.getUint16(0, true) = ', dataview.getUint16(0, true));
console.log('dataview.getUint8(0) = ', dataview.getUint8(0));
console.log('dataview.getUint8(1) = ', dataview.getUint8(1));

Los miembros de los objetos de tipo DataView son:

Propiedad/MétodoDescripción
.bufferdevuelve el ArrayBuffer asociado.
.byteLengthdevuelve la longitud (en bytes).
.byteOffsetdevuelve el desplazamiento (en bytes) de los valores manejados desde el inicio del ArrayBuffer.
.getInt8(offset)obtiene un entero de 8 bits con signo (byte) de la posición del offset.
.getUint8(offset)obtiene un entero de 8 bits sin signo (unsigned byte) de la posición del offset.
.getInt16(offset [, littleEndian])obtiene un entero de 16 bits con signo (short) de la posición del offset.
.getUint16(offset [, littleEndian])obtiene un entero de 16 bits sin signo (unsigned short) de la posición del offset.
.getInt32(offset [, littleEndian])obtiene un entero de 32 bits con signo (long) de la posición del offset.
.getUint32(offset [, littleEndian])obtiene un entero de 32 bits sin signo (unsigned long) de la posición del offset.
.getFloat32(offset [, littleEndian])obtiene un valor de coma flotante de 32 bits con signo (float) de la posición del offset.
.getFloat64(offset [, littleEndian])obtiene un valor de coma flotante de 64 bits con signo (doble) de la posición del offset.
.setInt8(offset, value)almacena un valor entero de 8 bits con signo (byte) en la posición del offset.
.setUint8(offset, value)almacena un valor entero de 8 bits sin signo (unsigned byte) en el byte especificado offset.
.setInt16(offset, value [, littleEndian])almacena un valor entero de 16 bits (short) en la posición del offset.
.setUint16(offset, value [, littleEndian])almacena un valor entero de 16 bits sin signo (unsigned short) en la posición del offset.
.setInt32(offset, value [, littleEndian])almacena un valor entero de 32 bits con signo (long) en la posición del offset.
.setUint32(offset, value [, littleEndian])almacena un valor entero 32 bit sin signo (unsigned long) en la posición del offset.
.setFloat32(offset, value [, littleEndian])almacena un valor de coma flotante de 32 (float) en la posición del offset.
.setFloat64(offset, value [, littleEndian])almacena un valor de coma flotante de 64 bits (double) en la posición del offset.

Para manejar estructuras binarias complejas, DataView ofrece una interfaz más completa y flexible que las matrices con tipo, que son más adecuadas cuando todos los valores que vamos a manejar son exactamente del mismo tipo.

Comparar Matrices con tipo

Realmente no tenemos que hacer nada especial para comparar dos matrices tipadas, funcionan igual que el resto de las matrices y como funcionará como hemos explicado en comparación de objetos. Básicamente se compararán uno a uno todos los elementos y si son iguales, podemos considerar que son equivalentes. Si queremos comparar dos matrices de tipos diferentes, debemos comparar el buffer asociado, ya que por si mismas son matrices diferentes.

Comparar ArrayBuffer y DataView

Lo primero que tenemos que hacer es comprobar si el entorno donde se está ejecutando tiene soporte para los objetos ArrayBuffer y DataView. Recordemos que en equal(), aunque tenemos que soportar todas las funcionalidades de ES6, debe ser capaz de funcionar sin problemas en cualquier entorno con soporte para ES5.1, lo que no podemos hacer algo como a instanceof ArrayBuffer sin saber si ArrayBuffer existe. Para ello definimos dos variables de la siguiente forma:

var ARRAYBUFFER_SUPPORT = typeof ArrayBuffer !== 'undefined';
var DATAVIEW_SUPPORT    = typeof DataView !== 'undefined';

Nota: no hemos definido estas variables como constantes (const) por que nuestro código debe funcionar en ES 5.1 y este tipo de definición no está soportada en esa versión de Javascript.

A partir de aquí podemos incluir las líneas adecuadas para la comparación de los contenidos de los objetos ArrayBuffer y DataView:

if (ARRAYBUFFER_SUPPORT && DATAVIEW_SUPPORT &&   // ArrayBuffer
    (a instanceof ArrayBuffer || a instanceof DataView) &&
    (b instanceof ArrayBuffer || b instanceof DataView))
{
    aValue = a instanceof ArrayBuffer ? new DataView(a) : a;
    bValue = b instanceof ArrayBuffer ? new DataView(b) : b;
    if (aValue.byteLength !== bValue.byteLength) {  // Check size
        return NOT_EQUAL;
    }
    i = bValue.byteLength;                          // Check content
    while (i--) {
        if (aValue.getInt8(i) !== bValue.getInt8(i)) {  // nonStrict comparison is not supported
            return NOT_EQUAL;
        }
    }
}

Es bastante sencillo, lo que hemos hecho es crear un DataView e ir comparando todos los bytes por medio de llamadas a getInt8() por cada uno de los elementos.

Conclusiones

Manejar datos binarios es cada día más común en Javascript. Los ArrayBuffer, DataView y las matrices con tipo son una potente solución, tiene una interfaz bastante sencilla y con soporte nativo en la mayoría de las plataformas.

Como vemos, aunque parecía que nuestra función equal() ya era bastante completa, lo cierto es que no tenía en cuenta este tipo de objetos, que cada día son más importantes. Estamos ya a punto de terminar, aunque todavía nos falta algún elemento adicional. Puedes descargar la versión final de esqueal.js con npm.

Novedades

JSDayES en directo

Sigue en directo las charlas y talleres del impresionante JSDayES 2017. Ponentes de primer orden, tanto nacionales como internacionales, nos mostrarán todos los aspectos de Javascript. El viernes 12 de mayo de 16:00 a 21:00 y el sábado 13 de mayo de 9:30 a 21:00 (hora de Madrid).

The CITGM Diaries by Myles Borins [video]

Myles Borins de Google, miembro del CTC de Node.js (Node.js Core Technical Committee), nos cuenta (en inglés, por supuesto) como funciona CITGM (Canary In The Gold Mine), una herramienta que permite obtener un módulo de NPM y probarlo usando una versión específica de NodeJS.

Debate: Tecnologías de Front Web [vídeo]

Desde las principales comunidades de desarrollo de tecnologías de front (Madrid JS, Polymer Madrid, Angular Madrid y VueJS Madrid) se ha organizado este debate que pretende ser un ejercicio de sentido común en relación a las tecnologías de front actuales centradas en componentes.