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:
setUint8()
- set a byte as an unsigned 8-bit integer.setUint16()
- set two bytes as an unsigned 16-bit integer.setUint32()
- set four bytes as an unsigned 32-bit integer.setBigUint64()
- set eight bytes as an unsigned 64-bit integer.setInt8()
- set a byte as a signed 8-bit integer.setInt16()
- set two bytes as a signed 16-bit integer.setInt32()
- set four bytes as a signed 32-bit integer.setBigInt64()
- set eight bytes as a signed 64-bit integer.setFloat32()
- set four bytes as a single-precision floating-point number.setFloat64()
- set eight bytes as a double-precision floating-point number.
And following are all the corresponding get methods:
getUint8()
- get a byte as an unsigned 8-bit integer.getUint16()
- get two bytes as an unsigned 16-bit integer.getUint32()
- get four bytes as an unsigned 32-bit integer.getBigUint64()
- get eight bytes as an unsigned 64-bit integer.getInt8()
- get a byte as a signed 8-bit integer.getInt16()
- get two bytes as a signed 16-bit integer.getInt32()
- get four bytes as a signed 32-bit integer.getBigInt64()
- get eight bytes as a signed 64-bit integer.getFloat32()
- get four bytes as a single-precision floating-point number.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.
The idea is very simple - we have 8 bits of memory where each bit represents a power of 2, as illustrated below.
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:
The minimum number representable in uint8 format is 0
, while the maximum number is 255
.
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:
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:
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:
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.
Calling getUint16(1)
means that we're reading the highlighted part below:
..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:
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:
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:
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);
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 byteOffset
s this time increment by 8 because:
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:
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:
The minimum number representable is -32768
whereas the maximum number is 32767
.
-32768
comes from -(216-1)
, whereas 32767
comes from 216-1 - 1
.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:
The minimum number representable is -2147483648
whereas the maximum number is 2147483647
.
-2147483648
comes from -(232-1)
, whereas 2147483647
comes from 232-1 - 1
.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:
The minimum number representable is -9223372036854775808
whereas the maximum number is 9223372036854775807
.
-9223372036854775808
comes from -(264-1)
, whereas 9223372036854775807
comes from 264-1 - 1
.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:
- Single-precision numbers spanning 32 bits
- 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).
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 0
s or will all 1
s 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.
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:
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 0
s or with all 1
s 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.