Typed Arrays
Typed arrays are special-purpose arrays designed to work with numeric types (not all types, as the name might imply). The origin of typed arrays can be traced to WebGL, a port of Open GL ES 2.0 designed for use in web pages with the <canvas>
element. Typed arrays were created as part of the port to provide fast bitwise arithmetic in JavaScript.
Arithmetic on native JavaScript numbers was too slow for WebGL because the numbers were stored in a 64-bit floating-point format and converted to 32-bit integers as needed. Typed arrays were introduced to circumvent this limitation and provide better performance for arithmetic operations. The concept is that any single number can be treated like an array of bits and thus can use the familiar methods available on JavaScript arrays.
ECMAScript 6 adopted typed arrays as a formal part of the language to ensure better compatibility across JavaScript engines and interoperability with JavaScript arrays. While the ECMAScript 6 version of typed arrays is not exactly the same as the WebGL version, there are enough similarities to make the ECMAScript 6 version an evolution of the WebGL version rather than a different approach.
Numeric Data Types
JavaScript numbers are stored in IEEE 754 format, which uses 64 bits to store a floating-point representation of the number. This format represents both integers and floats in JavaScript, with conversion between the two formats happening frequently as numbers change. Typed arrays allow the storage and manipulation of eight different numeric types:
- Signed 8-bit integer (int8)
- Unsigned 8-bit integer (uint8)
- Signed 16-bit integer (int16)
- Unsigned 16-bit integer (uint16)
- Signed 32-bit integer (int32)
- Unsigned 32-bit integer (uint32)
- 32-bit float (float32)
- 64-bit float (float64)
If you represent a number that fits in an int8 as a normal JavaScript number, you’ll waste 56 bits. Those bits might better be used to store additional int8 values or any other number that requires less than 56 bits. Using bits more efficiently is one of the use cases typed arrays address.
All of the operations and objects related to typed arrays are centered around these eight data types. In order to use them, though, you’ll need to create an array buffer to store the data.
I> In this book, I will refer to these types by the abbreviations I showed in parentheses. Those abbreviations don’t appear in actual JavaScript code; they’re just a shorthand for the much longer descriptions.
Array Buffers
The foundation for all typed arrays is an array buffer, which is a memory location that can contain a specified number of bytes. Creating an array buffer is akin to calling malloc()
in C to allocate memory without specifying what the memory block contains. You can create an array buffer by using the ArrayBuffer
constructor as follows:
let buffer = new ArrayBuffer(10); // allocate 10 bytes
Just pass the number of bytes the array buffer should contain when you call the constructor. This let
statement creates an array buffer 10 bytes long. Once an array buffer is created, you can retrieve the number of bytes in it by checking the byteLength
property:
let buffer = new ArrayBuffer(10); // allocate 10 bytes
console.log(buffer.byteLength); // 10
You can also use the slice()
method to create a new array buffer that contains part of an existing array buffer. The slice()
method works like the slice()
method on arrays: you pass it the start index and end index as arguments, and it returns a new ArrayBuffer
instance comprised of those elements from the original. For example:
let buffer = new ArrayBuffer(10); // allocate 10 bytes
let buffer2 = buffer.slice(4, 6);
console.log(buffer2.byteLength); // 2
In this code, buffer2
is created by extracting the bytes at indices 4 and 5. Just like when you call the array version of this method, the second argument to slice()
is exclusive.
Of course, creating a storage location isn’t very helpful without being able to write data into that location. To do so, you’ll need to create a view.
I> An array buffer always represents the exact number of bytes specified when it was created. You can change the data contained within an array buffer, but never the size of the array buffer itself.
Manipulating Array Buffers with Views
Array buffers represent memory locations, and views are the interfaces you’ll use to manipulate that memory. A view operates on an array buffer or a subset of an array buffer’s bytes, reading and writing data in one of the numeric data types. The DataView
type is a generic view on an array buffer that allows you to operate on all eight numeric data types.
To use a DataView
, first create an instance of ArrayBuffer
and use it to create a new DataView
. Here’s an example:
let buffer = new ArrayBuffer(10),
view = new DataView(buffer);
The view
object in this example has access to all 10 bytes in buffer
. You can also create a view over just a portion of a buffer. Just provide a byte offset and, optionally, the number of bytes to include from that offset. When a number of bytes isn’t included, theDataView
will go from the offset to the end of the buffer by default. For example:
let buffer = new ArrayBuffer(10),
view = new DataView(buffer, 5, 2); // cover bytes 5 and 6
Here, view
operates only on the bytes at indices 5 and 6. This approach allows you to create several views over the same array buffer, which can be useful if you want to use a single memory location for an entire application rather than dynamically allocating space as needed.
Retrieving View Information
You can retrieve information about a view by fetching the following read-only properties:
buffer
- The array buffer that the view is tied tobyteOffset
- The second argument to theDataView
constructor, if provided (0 by default)byteLength
- The third argument to theDataView
constructor, if provided (the buffer’sbyteLength
by default)
Using these properties, you can inspect exactly where a view is operating, like this:
let buffer = new ArrayBuffer(10),
view1 = new DataView(buffer), // cover all bytes
view2 = new DataView(buffer, 5, 2); // cover bytes 5 and 6
console.log(view1.buffer === buffer); // true
console.log(view2.buffer === buffer); // true
console.log(view1.byteOffset); // 0
console.log(view2.byteOffset); // 5
console.log(view1.byteLength); // 10
console.log(view2.byteLength); // 2
This code creates view1
, a view over the entire array buffer, and view2
, which operates on a small section of the array buffer. These views have equivalent buffer
properties because both work on the same array buffer. The byteOffset
and byteLength
are different for each view, however. They reflect the portion of the array buffer where each view operates.
Of course, reading information about memory isn’t very useful on its own. You need to write data into and read data out of that memory to get any benefit.
Reading and Writing Data
For each of JavaScript’s eight numeric data types, the DataView
prototype has a method to write data and a method to read data from an array buffer. The method names all begin with either “set” or “get” and are followed by the data type abbreviation. For instance, here’s a list of the read and write methods that can operate on int8 and uint8 values:
getInt8(byteOffset)
- Read an int8 starting atbyteOffset
setInt8(byteOffset, value)
- Write an int8 starting atbyteOffset
getUint8(byteOffset)
- Read an uint8 starting atbyteOffset
setUint8(byteOffset, value)
- Write an uint8 starting atbyteOffset
The “get” methods accept a single argument: the byte offset to read from. The “set” methods accept two arguments: the byte offset to write at and the value to write.
Though I’ve only shown the methods you can use with 8-bit values, the same methods exist for operating on 16- and 32-bit values. Just replace the 8
in each name with 16
or 32
. Alongside all those integer methods, DataView
also has the following read and write methods for floating point numbers:
getFloat32(byteOffset, littleEndian)
- Read a float32 starting atbyteOffset
setFloat32(byteOffset, value, littleEndian)
- Write a float32 starting atbyteOffset
getFloat64(byteOffset, littleEndian)
- Read a float64 starting atbyteOffset
setFloat64(byteOffset, value, littleEndian)
- Write a float64 starting atbyteOffset
The float-related methods are only different in that they accept an additional optional boolean indicating whether the value should be read or written as little-endian. (Little-endian means the least significant byte is at byte 0, instead of in the last byte.)
To see a “set” and a “get” method in action, consider the following example:
let buffer = new ArrayBuffer(2),
view = new DataView(buffer);
view.setInt8(0, 5);
view.setInt8(1, -1);
console.log(view.getInt8(0)); // 5
console.log(view.getInt8(1)); // -1
This code uses a two-byte array buffer to store two int8 values. The first value is set at offset 0 and the second is at offset 1, reflecting that each value spans a full byte (8 bits). Those values are later retrieved from their positions with the getInt8()
method. While this example uses int8 values, you can use any of the eight numeric types with their corresponding methods.
Views are interesting because they allow you to read and write in any format at any point in time, regardless of how data was previously stored. For instance, writing two int8 values and reading the buffer with an int16 method works just fine, as in this example:
let buffer = new ArrayBuffer(2),
view = new DataView(buffer);
view.setInt8(0, 5);
view.setInt8(1, -1);
console.log(view.getInt16(0)); // 1535
console.log(view.getInt8(0)); // 5
console.log(view.getInt8(1)); // -1
The call to view.getInt16(0)
reads all bytes in the view and interprets those bytes as the number 1535. To understand why this happens, take a look at Figure 10-1, which shows what each setInt8()
line does to the array buffer.
new ArrayBuffer(2) 0000000000000000
view.setInt8(0, 5); 0000010100000000
view.setInt8(1, -1); 0000010111111111
The array buffer starts with 16 bits that are all zero. Writing 5
to the first byte with setInt8()
introduces a couple of 1s (in 8-bit representation, 5 is 00000101). Writing -1 to the second byte sets all bits in that byte to 1, which is the two’s complement representation of -1. After the second setInt8()
call, the array buffer contains 16 bits, and getInt16()
reads those bits as a single 16-bit integer, which is 1535 in decimal.
The DataView
object is perfect for use cases that mix different data types in this way. However, if you’re only using one specific data type, then the type-specific views are a better choice.
Typed Arrays Are Views
ECMAScript 6 typed arrays are actually type-specific views for array buffers. Instead of using a generic DataView
object to operate on an array buffer, you can use objects that enforce specific data types. There are eight type-specific views corresponding to the eight numeric data types, plus an additional option for uint8
values.
Table 10-1 shows an abbreviated version of the complete list of type-specific views from section 22.2 of the ECMAScript 6 specification.
Constructor Name | Element Size (in bytes) | Description | Equivalent C Type |
---|---|---|---|
Int8Array | 1 | 8-bit two’s complement signed integer | signed char |
Uint8Array | 1 | 8-bit unsigned integer | unsigned char |
Uint8ClampedArray | 1 | 8-bit unsigned integer (clamped conversion) | unsigned char |
Int16Array | 2 | 16-bit two’s complement signed integer | short |
Uint16Array | 2 | 16-bit unsigned integer | unsigned short |
Int32Array | 4 | 32-bit two’s complement signed integer | int |
Uint32Array | 4 | 32-bit unsigned integer | int |
Float32Array | 4 | 32-bit IEEE floating point | float |
Float64Array | 8 | 64-bit IEEE floating point | double |
The left column lists the typed array constructors, and the other columns describe the data each typed array can contain. A Uint8ClampedArray
is the same as a Uint8Array
unless values in the array buffer are less than 0 or greater than 255. A Uint8ClampedArray
converts values lower than 0 to 0 (-1 becomes 0, for instance) and converts values higher than 255 to 255 (so 300 becomes 255).
Typed array operations only work on a particular type of data. For example, all operations on Int8Array
use int8
values. The size of an element in a typed array also depends on the type of array. While an element in an Int8Array
is a single byte long, Float64Array
uses eight bytes per element. Fortunately, the elements are accessed using numeric indices just like regular arrays, allowing you to avoid the somewhat awkward calls to the “set” and “get” methods of DataView
.
A> ### Element Size A> A> Each typed array is made up of a number of elements, and the element size is the number of bytes each element represents. This value is stored on a BYTES_PER_ELEMENT
property on each constructor and each instance, so you can easily query the element size: A> A> js A> console.log(UInt8Array.BYTES_PER_ELEMENT); // 1 A> console.log(UInt16Array.BYTES_PER_ELEMENT); // 2 A> A> let ints = new Int8Array(5); A> console.log(ints.BYTES_PER_ELEMENT); // 1 A>
Creating Type-Specific Views
Typed array constructors accept multiple types of arguments, so there are a few ways to create typed arrays. First, you can create a new typed array by passing the same arguments DataView
takes (an array buffer, an optional byte offset, and an optional byte length). For example:
let buffer = new ArrayBuffer(10),
view1 = new Int8Array(buffer),
view2 = new Int8Array(buffer, 5, 2);
console.log(view1.buffer === buffer); // true
console.log(view2.buffer === buffer); // true
console.log(view1.byteOffset); // 0
console.log(view2.byteOffset); // 5
console.log(view1.byteLength); // 10
console.log(view2.byteLength); // 2
In this code, the two views are both Int8Array
instances that use buffer
. Both view1
and view2
have the same buffer
, byteOffset
, and byteLength
properties that exist on DataView
instances. It’s easy to switch to using a typed array wherever you use a DataView
so long as you only work with one numeric type.
The second way to create a typed array is to pass a single number to the constructor. That number represents the number of elements (not bytes) to allocate to the array. The constructor will create a new buffer with the correct number of bytes to represent that number of array elements, and you can access the number of elements in the array by using the length
property. For example:
let ints = new Int16Array(2),
floats = new Float32Array(5);
console.log(ints.byteLength); // 4
console.log(ints.length); // 2
console.log(floats.byteLength); // 20
console.log(floats.length); // 5
The ints
array is created with space for two elements. Each 16-bit integer requires two bytes per value, so the array is allocated four bytes. The floats
array is created to hold five elements, so the number of bytes required is 20 (four bytes per element). In both cases, a new buffer is created and can be accessed using the buffer
property if necessary.
W> If no argument is passed to a typed array constructor, the constructor acts as if 0
was passed. This creates a typed array that cannot hold data because zero bytes are allocated to the buffer.
The third way to create a typed array is to pass an object as the only argument to the constructor. The object can be any of the following:
- A Typed Array - Each element is copied into a new element on the new typed array. For example, if you pass an int8 to the
Int16Array
constructor, the int8 values would be copied into an int16 array. The new typed array has a different array buffer than the one that was passed in. - An Iterable - The object’s iterator is called to retrieve the items to insert into the typed array. The constructor will throw an error if any elements are invalid for the view type.
- An Array - The elements of the array are copied into a new typed array. The constructor will throw an error if any elements are invalid for the type.
- An Array-Like Object - Behaves the same as an array.
In each of these cases, a new typed array is created with the data from the source object. This can be especially useful when you want to initialize a typed array with some values, like this:
let ints1 = new Int16Array([25, 50]),
ints2 = new Int32Array(ints1);
console.log(ints1.buffer === ints2.buffer); // false
console.log(ints1.byteLength); // 4
console.log(ints1.length); // 2
console.log(ints1[0]); // 25
console.log(ints1[1]); // 50
console.log(ints2.byteLength); // 8
console.log(ints2.length); // 2
console.log(ints2[0]); // 25
console.log(ints2[1]); // 50
This example creates an Int16Array
and initializes it with an array of two values. Then, an Int32Array
is created and passed the Int16Array
. The values 25 and 50 are copied from ints1
into ints2
as the two typed arrays have completely separate buffers. The same numbers are represented in both typed arrays, but ints2
has eight bytes to represent the data while ints1
has only four.