The DataView Interface

Chapter 26 26 mins

Learning outcomes:

  1. What are buffers
  2. The ArrayBuffer interface
  3. What are views
  4. The DataView interface
  5. Quick example

Introduction

In the previous chapter, we covered the basics of buffers in JavaScript, in particular the ArrayBuffer class and a slight overview of the DataView interface.

We saw how vast the DataView interface is and explored the methods setUint8() and getUint8(). Now in this chapter, we shall accel our understanding and exploration of the DataView interface, by unraveling all its methods.

Yup. All of them!

Specifically, we'll see what numbers formats do they represent, how are their binary data layed out, how to go from one type to another and much more on this road.

So what are we waiting for? Let's begin!

What is DataView?

Although this area has been well explored in the previous chapter, let's review it to get a good start on the topic.

DataView is simply an interface that allows us to put stuff into a buffer and then get stuff out of it.

It is what stands in between the developer and the buffer.

There's just no way we can interact with a buffer without a view - it's a MUST!

To boil it down, DataView is a view interface made so that developers can actually work with ArrayBuffer.

The DataView interface defines many methods to set and get data out of a buffer.

Following are all set methods:

  1. setUint8() - set a byte as an unsigned 8-bit integer.
  2. setUint16() - set two bytes as an unsigned 16-bit integer.
  3. setUint32() - set four bytes as an unsigned 32-bit integer.
  4. setBigUint64() - set eight bytes as an unsigned 64-bit integer.
  5. setInt8() - set a byte as a signed 8-bit integer.
  6. setInt16() - set two bytes as a signed 16-bit integer.
  7. setInt32() - set four bytes as a signed 32-bit integer.
  8. setBigInt64() - set eight bytes as a signed 64-bit integer.
  9. setFloat32() - set four bytes as a single-precision floating-point number.
  10. setFloat64() - set eight bytes as a double-precision floating-point number.

And following are all the corresponding get methods:

  1. getUint8() - get a byte as an unsigned 8-bit integer.
  2. getUint16() - get two bytes as an unsigned 16-bit integer.
  3. getUint32() - get four bytes as an unsigned 32-bit integer.
  4. getBigUint64() - get eight bytes as an unsigned 64-bit integer.
  5. getInt8() - get a byte as a signed 8-bit integer.
  6. getInt16() - get two bytes as a signed 16-bit integer.
  7. getInt32() - get four bytes as a signed 32-bit integer.
  8. getBigInt64() - get eight bytes as a signed 64-bit integer.
  9. getFloat32() - get four bytes as a single-precision floating-point number.
  10. getFloat64() - get eight bytes as a double-precision floating-point number.

Let's start with the first category of methods - ones that revolve around unsigned integers.

Unsigned integers

Out of the formats to store data (i.e numbers) in a buffer, the most straightforward format is that of unsigned numbers.

The most significant bit of unsigned integers represents part of the number's magnitude. That is, it isn't reserved for the sign of the number as is the case with signed integers.

Typically there are four subcategories of unsigned integers, solely based on their byte sizes. We have 8-bit, 16-bit, 32-bit and finally 64-bit large unsigned integers.

This unsigned format is very easy to understand. In fact, people being introduced to binary numbers are generally given example of unsigned numbers.

In the section below, we'll start

8-bit

The unsigned 8-bit integer format, as the name implies represents an unsigned number that takes up a single byte to be stored.

8-bits make up a byte!

The idea is very simple - we have 8 bits of memory where each bit represents a power of 2, as illustrated below.

128
0
64
0
32
0
16
0
8
0
4
0
2
0
1
0

A 0 bit means that the value is not to be taken into account, whereas a 1 bit means that it has to be taken into account.

The number 30 would therefore be represented as follows:

128
0
64
0
32
0
16
1
8
1
4
1
2
1
1
0

The minimum number representable in uint8 format is 0, while the maximum number is 255.

The number 255 comes from 28 - 1, where the exponent 8 is the bit-size of the uint8 format.

With this in mind let's explore the methods to work with uint8 numbers - setUint8() and getUint8().

Say we want to store te three numbers 15, 27 and 199 in uint8 format.

We'll start by constructing the buffer and a view on it:

var buffer = new ArrayBuffer(3);
var view = new DataView(buffer);

And with this done, we'll call setUint8() for each number, as shown below:

view.setUint8(0, 15);
view.setUint8(1, 27);
view.setUint8(2, 199);

Take note of the first byteOffset arguments here.

The first number begins at the byte offset 0 and takes up the whole byte. The second number will therefore begin at byte offset 1 and take up that. Finally, the last number begins at the byte offset 2 and as before consumes the whole byte.

In short:

Each uint8 number's storage causes the byte offset for the next number to increment by 1; simply because uint8 numbers take up 1 byte.

Now to get all these numbers, we'll use getUint8() as is shown in the following code:

view.getUint8(0); // 15
view.getUint8(1); // 27
view.getUint8(2); // 199

The method extract a whole given byte and converts its raw binary data into an unsigned 8-bit integer.

Isn't this simple?

16-bit

If 8-bit don't suffice your needs, the next sensible option is to use 16-bits.

The unsigned 16-bit integer format, or simply uint16, represents an unsigned integer that takes up two bytes of memory.

It's nothing new - just an extension to the 8-bit format.

Shown below is an illustration of a uint16 number:

32768
0
16384
0
...
4
0
2
0
1
0

The minimum number representable is, as before, 0 whereas the maximum number is 65535.

65535 is 216 - 1, where 16 is the bit-size of the uint16 format.

The methods that operate on the unsigned 16-bit format are setUint16() and getUint16().

Say you want to store the two numbers 309 and 2078 in uint16 format.

First realise that since each uint16 number takes up two bytes - two numbers will take up four bytes, which means that the buffer will have to be at least four bytes long.

Following we create one, exactly four bytes in length:

var buffer = new ArrayBuffer(4);
var view = new DataView(buffer);

After this we need to put the numbers into the buffer using setUint16() and then afterwards retrieve them using getUint16().

view.setUint16(0, 309);
view.setUint16(2, 2078);

Take note of the byteOffset arguments here as well.

The first number starts at byte offset 0. It takes up that byte and the second one too. This means that position 0 and 1 are occupied, and so to put the second number we ought to go with the byte offset 2.

The second number will consume positions 2 and 3; and so on and so forth..

In short:

Each uint16 number's storage causes the byte offset for the next number to increment by 2; simply because uint16 numbers take up 2 bytes.

Anyways, in the same way we stored the data, we will now retrieve it, using getUint16():

view.getUint16(0); // 309
view.getUint16(2); // 2078

And we're done! Quite simple - wasn't it?

One important thing you need to understand over here is that if you call view.getUint16(1) with 1 as an argument, the uint16 number spanning byte offsets 1 and 2 will be returned.

Following is an illustration of buffer when the numbers 309 and 2078 are put into it.

00000001001101010000100000011110

Calling getUint16(1) means that we're reading the highlighted part below:

00000001001101010000100000011110

..which is the number 0b00110101_00001000, or 0x3508, or simply 13576. Let's see this for real.

console.log(0b0011010100001000); // 13576
view.getUint16(1); // 13576

We'll see more such examples in detail when we study endianness.

32-bit

If even 16-bits can't accomodate your data, the next option is to try out 32-bits.

The unsigned 32-bit integer format, or simple uint32, represents an unsigned number that consumes four bytes.

Here's an illustration:

231
0
230
0
...
4
0
2
0
1
0

The minimum number representable, as always, is 0 whereas the maximum number is 4294967295.

4294967295 comes from 232 - 1, where 32 is the bit-size of a uint32 number.

The methods setUint32() and getUint32() are what deal with 32-bit numbers.

Say you want to store the two numbers 75600 and 968550 in uint32 format.

Owing to these numbers, the buffer to hold them will have to be at least 8 bytes long. Following is the code to store the numbers:

var buffer = new ArrayBuffer(8);
var view = new DataView(buffer);

view.setUint32(0, 75600);
view.setUint32(4, 968550);

As before, it's important that you take note of the byteOffset arguments here.

The first number begins at offset 0 and consumes four bytes i.e positions 0, 1, 2 and 3. This leaves us with position 4 to allocate to the next number.

In short:

Each uint32 number's storage causes the byte offset for the next number to increment by 4; simply because uint32 numbers take up 4 bytes.

And by this stage you would've already guessed how to retrieve both these numbers back from buffer:

view.getUint32(0); // 75600
view.getUint16(4); // 968550

Piece of cake!

64-bit

The last resort to store data, after 32 bits fail one's requirements is to use 64-bits. This is the last subcategory of unsigned integers.

The unsigned 64-bit integer format, or simple uint64, represents an unsigned number that consumes eight bytes.

Consider the illustration below:

263
0
262
0
...
4
0
2
0
1
0

The minimum number representable, as always, is 0 whereas the maximum number is 18446744073709551615.

18446744073709551615 comes from 264 - 1, where 64 is the bit-size of a uint64 number.

The methods getBigUint64() and setBigUint64() are what work with the 64-bit representation.

Why are they called 'Big'?

If you're thinking why they are called 'BigUint64' then here's the explanation for it.

The maximum integer that can be safely represented in JavaScript's native double-precision floating point format is 253 - 1. This means that 64-bit numbers like 260 can't be precisely represented.

To cater to this problem, JavaScript introduced the BigInt API that can exactly represent big integers - even beyond 2100000!

The methods setBigUint64() and getBigUint64() utilise this API while putting or getting data, and therefore use the word 'Big' in their names.

Names tell a lot about an identifier!

Anyways let's consider a quick example.

Say you want to store the numbers 958668545033 and 34359738368 in uint64 format (obviously!). The code to do so will resemble the snippet below:

var buffer = new ArrayBuffer(16);
var view = new DataView(buffer);

view.setBigUint64(0, 1586685450337n);
view.setBigUint64(8, 34359738368n);
Notice the n in the numbers 1586685450337n and 34359738368n - this is the literal way to write out BigInt numbers, and even the required type of the setBigUint64() method.

The byteOffsets this time increment by 8 because:

Each uint64 number's storage causes the byte offset for the next number to increment by 8; as uint64 numbers take up 8 bytes.

Consider the retrieval code below:

view.getBigUint64(0); // 1586685450337n
view.getBigUint64(8); // 34359738368n

Once again, simple as simplicity!

Signed integers

The second category of integers to be discussed in this chapter is signed integers.

As is obvious to realise, signed integers are composed of two parts: a sign and a magnitude. The format used is the one typically used for in almost all electronic systems today i.e two's complement.

The most significant bit (MSB) represents the sign of the number. 0 is for positive whereas 1 is for negative.

To store a negative number, first its positive counterpart is stored, then all its bits are switched and finally one is added, which gives the number to be put into the memory.

For simplicity, we can take it this way that the MSB represents -(2r-1), where r is the bit-size of the numeric format.

Let's start exploring all subcategories of signed integers...

8-bit

The signed 8-bit integer format, or int8, represents signed integers that take up a single byte.

Following is a representation of the format:

-128
0
64
0
32
0
16
0
8
0
4
0
2
0
1
0

The minimum number representable is -128 whereas the maximum number is 127.

-128 comes from 28 - 1, whereas 127 comes from 28-1 - 1.

To work with int8 numbers we have the methods setInt8() and getInt8().

Consider the code below, where we store the three numbers -120, 50 and 3 in int8 format:

var buffer = new ArrayBuffer(3);
var view = new DataView(buffer);

view.setInt8(0, -120);
view.setInt8(1, 50);
view.setInt8(2, 3);

To retrieve these numbers, we'll merely call getInt8():

view.getInt8(0); // -120
view.getInt8(1); // 50
view.getInt8(2); // 3

The byteOffset arguments here follow the same story as before. For 8-bit integers, the offset increments are of 1; since they occupy 1 byte.

Moving on to the next format...

16-bit

The signed 16-bit integer format, or int16, represents signed integers that take up two bytes.

Following is a representation of the format:

-32768
0
16384
0
...
4
0
2
0
1
0

The minimum number representable is -32768 whereas the maximum number is 32767.

-32768 comes from -(216-1), whereas 32767 comes from 216-1 - 1.
In Java, signed 16-bit integers have the numeric type short.

As you can guess, int16 numbers go with the methods setInt16() and getInt16().

Consider the code below, where we store the two numbers -3056 and -100 in int16 format:

var buffer = new ArrayBuffer(4);
var view = new DataView(buffer);

view.setInt8(0, -3056);
view.setInt8(2, -100);

To retrieve these numbers, we'll correspondingly call getInt16():

view.getInt16(0); // -3056
view.getInt16(2); // -100

With 2 bytes consumed per integer, the int16 format has byteOffset increments of 2.

32-bit

The signed 32-bit integer format, or int32, represents signed integers that take up four bytes.

Following is a representation of the format:

-231
0
230
0
...
4
0
2
0
1
0

The minimum number representable is -2147483648 whereas the maximum number is 2147483647.

-2147483648 comes from -(232-1), whereas 2147483647 comes from 232-1 - 1.
In Java, signed 32-bit integers have the numeric type int, which is one of the most common data types used in the language.

32-bit signed integers have the friends setInt32() and getInt32().

In the following code, we store the four numbers -189645, -71643086, 68545 and -2 in int32 format:

var buffer = new ArrayBuffer(16);
var view = new DataView(buffer);

view.setInt32(0, -189645);
view.setInt32(4, -71643086);
view.setInt32(8, 68545);
view.setInt32(12, -2);

Demonstrating the correspondingly get method - getInt32() - we have the code shown below:

view.getInt32(0); // -189645
view.getInt32(0); // -71643086
view.getInt32(0); // 68545
view.getInt32(0); // -2

With 4 bytes consumed per integer this time, the int32 format has byteOffset increments of 4.

64-bit

The signed 64-bit integer format, or int64, represents signed integers that take up eight bytes.

Following is a representation of the format:

-263
0
262
0
...
4
0
2
0
1
0

The minimum number representable is -9223372036854775808 whereas the maximum number is 9223372036854775807.

-9223372036854775808 comes from -(264-1), whereas 9223372036854775807 comes from 264-1 - 1.
In Java, signed 64-bit integers have the numeric type long.

To operate on 64-bit signed numbers we've got the methods setBigInt64() and getBigInt64().

In the following code, we store the two numbers -289450006060048 and 8156792035 in int64 format:

var buffer = new ArrayBuffer(16);
var view = new DataView(buffer);

view.setBigInt64(0, -289450006060048n);
view.setBigInt64(8, 8156792035n);

Demonstrating the correspondingly get method - getBigInt64() - we have the code shown below:

view.setBigInt64(0); // -289450006060048n
view.setBigInt64(8); // 8156792035n

With 8 bytes consumed per integer this time, the int64 format has byteOffset increments of 8.

And this completes the second category of methods defined by the DataView interface. It's finally time to explore the last one - floating-point numbers.

Floating-point numbers

DataView has even got us covered if we need to work with floating-point data. There are two subcategories of floats, once again based on their bit-sizes.

We have:

  1. Single-precision numbers spanning 32 bits
  2. Double-precision numbers spanning 64 bits.

The format used is the standard IEEE 754 format. A number is composed of three segments: sign, exponent and mantissa.

Let's begin the exploration...

Single-precision (32-bits)

The single-precision 32-bit floating-point format, or simply, float32, represents real numbers that consume 4 bytes of memory.

It has 1 bit for the sign, 8 bits for the exponent and 23 bits for the mantissa, also known as the fraction, or the significand ; in this very order (starting from the MSB end).

Sign
0
31
Exponent
...
30 - 23
Mantissa
...
22 - 0
The numbers shown above in grey represent the positions of the bits in each segment.
In Java, single-precision numbers have the type float.

As with signed integers, 0 in the sign bit denotes a positive number while 1 denotes a negative number.

The exponent has a bias of -127 i.e to obtain the real exponent value we subtract 127 from the value stored in the exponent byte.

An exponent byte with all 0s or will all 1s denotes special values. In other words, both these bit sequences are reserved and therefore can't be used to represent real numbers.

In this way, the minimum representable exponent is -126 whereas the maximum is 127.

Moving on, the last part of this format i.e the mantissa has a maximum of 3.8 x 1038 whereas the minimum is its negative counterpart -3.8 x 1038. The smallest fractional number representable is 1.4 x 10-45.

The methods setFloat32() and getFloat32() are made to work with single-precision 32-bit floats.

Let's see an example:

Say you want to store the numbers -6.135 and Math.PI in the float32 format. The following code accomplishes the task:

var buffer = new ArrayBuffer(8);
var view = new DataView(buffer);

view.setFloat32(0, -6.135);
view.setFloat32(4, Math.PI);

As with the uint32 and int32 formats, notice that the byteOffset arguments here also increment by 4. This is because, each float32 number takes up 4 bytes of memory.

Let's even retrieve both these numbers and see what do we get:

view.getFloat32(0); // -6.135000228881836
view.getFloat32(4); // 3.1415927410125732

If you notice, the values returned here are different as compared to the ones we actually stored.

This happens solely because of the way floating-point conversions happen - not every number can be represented exactly. If you know how the conversions work, you'll easily be able to understand why are the returned values different.

JavaScript rounds floating-point numbers to a given precision when outputting them. We never get to see the literal representation of numbers that can't be precisely expressed!

Double-precision (64-bit):

The double-precision 64-bit floating-point format, or simply, float64, represents real numbers that consume 8 bytes of memory.

It has 1 bit for the sign, 11 bits for the exponent and 52 bits for the mantissa; in this very order (starting from the MSB end).

Below shown is an illustration:

Sign
0
63
Exponent
...
62 - 52
Mantissa
...
51 - 0
In Java, double-precision numbers have the type double.

This time the exponent has a bias of -1023 i.e to obtain the real exponent value we subtract 1023 from the value stored in the exponent byte.

As before, the exponent bytes with all 0s or with all 1s are reserved for special values.

In this way, the minimum representable exponent is -1022 whereas the maximum is 1027.

The mantissa has a maximum of 1.8 x 10308 while the minimum is, obviously, its negative counterpart -1.8 x 10308. The smallest fractional number representable is 4.9 x 10-324.

The methods setFloat64() and getFloat64() are made to work with double-precision 64-bit floats.

In the following code we store the numbers 4 ** 201 and -0.000015652 in the float64 format:

var buffer = new ArrayBuffer(16);
var view = new DataView(buffer);

view.setFloat64(0, 4 ** 201);
view.setFloat64(4, -0.000015652);

As with the uint64 and int64 formats, notice that the byteOffset arguments here also increment by 8. This is because, each float64 number takes up 8 bytes of memory.

Retrieving both these numbers leads to the following:

view.getFloat64(0); // 1.0328999512347634e+121
view.getFloat32(8); // -0.000015652

If you notice, the values returned here are similar to the ones we actually stored.

This is the beauty of floating-point arithmetic and rounding - sometimes it can turn out to be exact as compared to the original value!

Moving on..

In the next chapter, as we've said before, we shall explore the DataView interface from crust to core. We'll see the details of all its get and set methods, and then look over a simplification done to this view model i.e typed arrays.

Finally, we shall understand what's the purpose of the last littleEndian argument of all the DataView's get and set methods, when we unveil the concept of endianness, in the last chapter.