Введение в типизированные массивы и интерфейс DataView в JavaScript
Ознакомимся с классами для работы с бинарными данными и типизированными массивами в языке JavaScript. Как мне удалось накопать, они являются частью стандартизации ES6. И главной причиной их внедрения было улучшение производительности при работе с WebGL. Так как работа с обычными массивами JavaScript занимает больше процессорного времени, чем с типизированными массивами как в языке C (GLSL ES). WebGL под капотом выполняет "шейдерный" код, который берет двоичные данные из того буффера, которую выделил JavaScript. Но то, что будем проходить сегодня полезно не только для работы нашей видеокарты. Типизированные массивы используются при взаимодействии с сетью (HTTP или вебсокеты), с изображениями или для любой другой тяжелой задачи. Добавлю, что чтение статьи требует предварительных знаний про двоичное представление данных и знакомство с типами в C.
ArrayBuffer и SharedArrayBuffer
ArrayBuffer - базовый класс для работы с бинарными данными. Если кратко, то это обычная ссылка на непрерывную область памяти определенной длины. Эту длину мы указываем при создании экземпляра класса ArrayBuffer.
const buffer = new ArrayBuffer(1048576) // выделяем мегабайт памяти
console.log(buffer.byteLength) // 1048576
Название может запутать, но ArrayBuffer - это не массив, как объект JavaScript. Это массив ячеек памяти, где каждый ее элемент это байт информации. Мы не можем получить доступ для чтения и записи данных из буфера. Чтобы заполнить или прочитать из него мы должны использовать классы для представления(интерпретации) бинарных данных. Снизу список этих классов реализующих представление.
Тут вам понадобится знание системы типов в C:
-
Int8Array - интерпретирует каждый байт как отдельное целое число от -128 до 127. Соответствует типу int8_t в C.
-
Uint8Array - интерпретирует каждый байт как неотрицательное целое число от 0 до 255. Соответствует типу uint8_t в C.
-
Uint8ClampedArray - ничем не отличается от предыдущего, кроме того что берет нижнее или верхнее значение, если выйти за пределы значений.
-
Int16Array - интерпретирует каждые 2 байта как целое число от -32768 до 32767. Соответствует типу int16_t в C.
-
Uint8Array - интерпретирует каждые 2 байта как неотрицательное целое число от 0 до 65535. Соответствует типу uint16_t в C.
-
Int32Array - интерпретирует каждые 4 байта как целое число от --2,147,483,648 до 2,147,483,647. Соответствует типу int32_t в C.
-
Uint32Array - интерпретирует каждые 4 байта как неотрицательное целое число от 0 до 4,294,967,295. Соответствует типу uint32_t в C.
-
Float32Array - интерпретирует каждые 4 байта как число с плавающей точкой от -3.4e38 до 3.4e38. Соответствует типу float в C.
-
Float64Array - интерпретирует каждые 8 байта как число с плавающей точкой от -1.8e308 до 1.8x10e308. Соответствует типу double в C.
const arrayBuffer = new ArrayBuffer(1048576);
console.dir(arrayBuffer);
console.log(arrayBuffer.byteLength); // 1048576 - количество байт в буфере
console.log(typeof arrayBuffer); // Object
console.log(arrayBuffer instanceof ArrayBuffer); // true
console.log(Object.getPrototypeOf(arrayBuffer).constructor.name); // ArrayBuffer
const ui8a = new Uint8Array();
console.log(ui8a);
console.log(ArrayBuffer.isView(ui8a)); // true
Давайте исполним этот фрагмент кода в Node.js для более детального взгляда.
Объект SharedArrayBuffer ведет себя как ArrayBuffer, только он является ссылкой на распределяемую память между потоками. Это непередаваемый объект (non-transferable object), в смысле его нельзя передавать между потоками, потому что она и так доступна в них. Я более детально затрону SharedArrayBuffer в продолжении статей про многопоточность в Node.js
Мы также можем создать ArrayBuffer на основе какого-либа файла в Node.js:
const buffer = reader.readAsArrayBuffer(file);
const typedArray = new Int32Array(buffer);
Типизированные массивы
Классы-представления для чтения и записи двоичных данных можно разделить на две группы: типизированные массивы(Typed Arrays) и класс DataView. Это разделение можно увидеть на картине сверху. Про типизированные массивы мы уже начали говорить в предыдущем разделе. Это псевдомассивы объекты, которые не призваны заменить обычные Arrays в коде JavaScript. Их реальное предназначение мы уже обсудили в начале статьи - предоставить сырые данные для внешних API по типу WebGL, которые возможно написаны на C или других С-подобных языках. Псевдомассивы значит, что в этот объект встроено множество методов, которые схожи или повторяют методы объекта Array. Но если мы выполним:
const typedArray = Uint16Array([1,2,3,4])
console.log(Array.isArray(typedArray))
То это вернет false. Есть разные способы инициализировать типизированный массив. Мы покажем это на примере несуществующего класса TypedArray. Замените этот класс теми классами, которые мы обозрели выше и смысл не поменяется.
new TypedArray(buffer, byteOffset, length);
// Передаем ссылку на буфер, сдвиг на какое-то число байт и количество байт
new TypedArray(object);
// Кидаем ссылку на массив или на псевдомассив.
// Получается типизированный массив с тем же содержимым и такой же длины.
new TypedArray(typedArray);
// Передаем ссылку на уже существующий типизированный массив. Копируется длина и содержимое.
// При разнице в типах происходит приведение к нужным типам.
new TypedArray(length);
// Создаем типизированный массив определенной длины.
// Это именно количество элементов, а не байт
new TypedArray();
// Пустой типизированный массив
Во всех этих случаях нужно учесть, что под капотом все равно создается объект ArrayBuffer (если явно не передан). Это и логично, потому что они "представляют" бинарные данные. Чтобы получить доступ к этому объекту можно взять его из свойство buffer. Пробежимся по свойствам типизированного массива и создадим еще один из существующего.
const typedArray = new Uint16Array([0, 1, 2, 3]);
console.dir({
typedArray: typedArray,
second_element: typedArray[1],
length: typedArray.length,
bytes_per_element: typedArray.BYTES_PER_ELEMENT,
buffer: typedArray.buffer,
byteLength: typedArray.byteLength,
byteOffset: typedArray.byteOffset,
});
// Изменение типа
const typedArray32 = new Uint32Array(typedArray);
console.dir({
typedArray: typedArray32,
second_element: typedArray[1],
length: typedArray32.length,
bytes_per_element: typedArray32.BYTES_PER_ELEMENT,
buffer: typedArray32.buffer,
byteLength: typedArray32.byteLength,
byteOffset: typedArray32.byteOffset,
});
Типизированные массивы имеют фиксированную длину, поэтому в них отсутствуют методы, изменяющие их длину. К таким методам относятся: pop, push, shift, splice (следовательно и spliced), unshift. Методы по типу flat, concept, flatMap тоже недоступны, потому что внутри не может быть вложенных типизированных массивов. Остальные методы Array также реализованы и в TypedArray.
К тому же в типизированных массивах реализованы методы, которых нет в обычных массивах. К ним относятся set и subarray, которые нужны для случаев если несколько Typed Arrays представляют один и тот же буфер.
- typedArray.set(array, offset) копирует элементы из array в typedArray, начиная с offset (0 по умолчанию).
const typedArray = new Uint16Array(new ArrayBuffer(16));
console.log("initial typed array", typedArray);
typedArray.set([1, 2, 3, 4], 3);
console.log("after set", typedArray);
Вывод::
- typedArray.subarray(start, end) возвращает новый типизированный массив, который ссылается на тот же буфер и с тем же типом элементов. start - включительно и end - невключительно. Если end не указан, то берет до последнего элемента.
const uint8 = new Uint8Array([10, 20, 30, 40, 50]);
console.log(uint8.subarray(1, 3));
console.log(uint8.subarray(2));
Вывод:
Uint8Array(2) [ 20, 30 ]
Uint8Array(3) [ 30, 40, 50 ]
Какие есть подводные камни ? Разберем первый случай. Для этого запишем число 777 в качестве элемента типизированного массива Uint8Array.
const typedArray = new Uint8Array([255, 777]);
console.log(typedArray[0]);
console.log(typedArray[1]);
Вывод:
255
9
Наверняка вы догадываетесь почему 777 превратилось в 9. Тип uint8_t не может хранить число больше 255, поэтому при записи будут отброшены лишние биты числа 777.
777 -> 1100001001
Отбросив первые два наиболее значимых бита, останется 00001001, что равняется числу 9. Так что следует быть осторожным при конвертации типов, потому что может возникнуть ситуация, аналогичная той, что показана ниже:
// Case 1
const uint8Array1 = new Uint8Array(4);
uint8Array1[0] = 257;
const uint16Array1 = new Uint16Array(uint8Array1);
console.log(uint8Array1);
console.log(uint16Array1);
// Case 2
const uint16Array2 = new Uint16Array(4);
uint16Array2[0] = 257;
const uint8Array2 = new Uint8Array(uint16Array2);
console.log(uint8Array2);
console.log(uint16Array2);
На выходе получаем:
Uint8Array(4) [ 1, 0, 0, 0 ]
Uint16Array(4) [ 1, 0, 0, 0 ]
Uint8Array(4) [ 1, 0, 0, 0 ]
Uint16Array(4) [ 257, 0, 0, 0 ]
Мы можем изменить это поведение с помощью представления Uint8ClampedArray. В него записываются значения 255 для чисел, которые больше 255, и 0 для отрицательных чисел. Это поведение нужно при работе с изображениями в Canvas.
Примечание. Можно использовать шестнадцатеричную систему при присвоении, что считается более удобным, чем использование обычных целых чисел. Однако битовые операции будут выполняться в зависимости от типов данных, поэтому нужно быть внимательным при их использовании.
// Case 1
const int8Array = new Int8Array(4);
int8Array[0] = 0xF;
int8Array[1] = ~int8Array[0];
console.log(int8Array);
// Case 1
const uint8Array = new Uint8Array(4);
uint8Array[0] = 0xF;
uint8Array[1] = ~uint8Array[0];
console.log(uint8Array);
Результат
Int8Array(4) [ 15, -16, 0, 0 ]
Uint8Array(4) [ 15, 240, 0, 0 ]
DataView
DataView - это низкоуровневый интерфейс, который предоставляет API для чтения и записи произвольных данных в буфер. Другими словами, мы можем записывать данные различных типов в наш буфер. Еще одна отличительная черта DataView, мы можем изменять порядок байт (endianness). То есть при желании поменять BE(big endian) на LE(little endian), но по умолчанию стоит BE.
DataView не требует выравнивания - многобайтовое чтение и запись могут происходить с любым заданным смещением. Методы для вставки (сеттеры) работают таким же образом. Посмотрим на практике.
const buffer = new ArrayBuffer(8);
const dataView1 = new DataView(buffer);
const dataView2 = new DataView(buffer, 6, 2);
dataView1.setUint16(6, 6000);
console.dir({ dataView1, dataView2 });
console.log("first element of first DataView", dataView1.getUint16(0));
console.log("first element of second DataView", dataView2.getUint16(0));
Вывод:
{
dataView1: DataView {
byteLength: 8,
byteOffset: 0,
buffer: ArrayBuffer {
[Uint8Contents]: <00 00 00 00 00 00 17 70>,
byteLength: 8
}
},
dataView2: DataView {
byteLength: 2,
byteOffset: 6,
buffer: ArrayBuffer {
[Uint8Contents]: <00 00 00 00 00 00 17 70>,
byteLength: 8
}
}
}
first element of first DataView 0
first element of second DataView 6000
В DataView доступ к данным осуществляется посредством методов типа .getUint8(i) или .getUint16(i). Мы выбираем формат данных в момент обращения к ним, а не в момент их создания. Для начала рассмотрим, что будет происходить если мы будем вставлять данные с большим количеством бит, а читать будем с меньшим количеством.
const buffer = new ArrayBuffer(16);
const dataView = new DataView(buffer);
dataView.setUint16(0, 777);
console.log("[0]", dataView.getUint8(0));
console.log("[1]", dataView.getUint8(1));
Вывод:
[0] 3
[1] 9
Что же тут произошло? Чтобы лучше понять, давайте представим снова число 777 в двоичном виде как двухбайтное - 00000011 00001001. Это число в 16-битовом формате, но читаем мы ее как в 8-битовом. Поэтому давайте разделим ее на две равные части - 00000011 и 00001001, что равняется числам 3 и 9 соответственно.
Теперь давайте сделаем наоборот. Запишем число в меньшем количестве байт, а будем читать в большем.
const buffer = new ArrayBuffer(16);
const dataView = new DataView(buffer);
dataView.setUint8(0, 77);
console.log("[0]", dataView.getUint16(0));
Вывод:
[0] 19712
Получаем крайне странный ответ. Тут я скажу, что довольно долго мучался с получаемыми значениями. В интернете я ответа не нашел (видимо плохо искал), но зато удалось самому вывести "формулу" по которому такие значения получаются.
Так дело в том, что мы получаем пропорционально равное число, которую записали в меньшем количестве бит. Что я имею ввиду? Мы записали число 77 в 8-битном формате, которое имеет в сумме 256 чисел. В итоге у нас пропорция равняется 77/256 = 0.30078125. Теперь посмотрим, как соотносится число 19712 в 16-битовом формате с числом 65536 - 19712/65536 = 0.30078125. Мистика решена.
Под конец, я хочу показать как работает порядок байт в DataView. Для ее изменения при записи третим параметром должно идти булево значение, где true - это little endian, а false - big endian. При чтении мы указываем булево значение вторым параметром. По умолчанию в обеих случаях стоит false.
const buffer = new ArrayBuffer(16);
const dataView = new DataView(buffer);
dataView.setUint16(0, 777);
console.dir({
bigEndian: dataView.getInt16(0).toString(16), // default is big-endian
littleEndian: dataView.getInt(0, true).toString(16),
});
Вывод:
{ bigEndian: '309', littleEndian: '903' }