Manipulating numbers is the most common part of any algorithm and is something you use throughout any application. This section explores how numbers work in WAT and how to use the instructions available to control and manipulate them in the many different ways you need. It is important to understand what a number is, what it can do, and what it cannot do.
An integer is used to store whole numbers only.
They are the primary data type used in computing.
In WAT there are two integer types, i32
and i64
.
They work in the same sort of way but have different sizes.
You properly know how integers work, but here is a quick look.
The i32
integer type is made up of 32 bits, which can be either 0
or 1
, with the least significant bit on the right and the most significant bit on the left.
For example:
Bit: 31
30
29
28
27
26
25
24
... 7
6
5
4
3
2
1
0
We can write the number in binary (with only 0s and 1s) like this.
01001011 = 75 decimal
You may also see the same number written as hexadecimal (with 0 to 9, then A to F) like this.
0x4B = 75 decimal
The 32 bits is just data and on its own only represents a number.
It can be used in many different ways to represent different things.
For example, the value 75 decimal is the ASCII character K
.
To use negative values the integer will need to be handled in a different way. Normally we use all 32 bits of the number, but if we want to use negatives, then we use the 31st bit on the left, the most significant bit, as a special flag, to tell the different instructions that the number is negative and not positive. For example,
0xFFFFFFFF = 4294967295 decimal (using all 32 bits for the whole number) 0xFFFFFFFF = -1 decimal (bit 31 is a flag making it a negative number)
It is the same data but we are looking at it in two different ways. When the negative flag is not being used we call the integer unsigned, but when it is being used we call it signed. We will see a number of instructions that have both an unsigned and a signed version.
Most of the time you will be working with the i32
data type instead of the i64
data type.
Also, in JavaScript numbers are only converted into 32 bit integers.
You would need to use the BigInt
object to work with i64
values.
To work with real numbers, those that have decimal points, we cannot use normal integers, but instead we need to use a floating point type.
In WAT there are two available, f32
and f64
, both of which are basically the same but have different sizes,
with the 64 bit version having a higher precision.
The f32
type is using the same 32 bits as an i32
integer but it is being used to represent a floating point number instead.
This includes negative numbers too.
It is better to use the f64
type when working with floating point numbers, as the f32
data type is older and has lower precision.
Also, JavaScript numbers are using the 64 bit floating point data type too, so transferring between the two is more simple.
To add two i32
numbers together you use the i32.add
instruction.
You can also use the i64.add
, f32.add
and the f64.add
instructions for different data types.
You cannot add one data type to a different one however.
;; Push the i32 value 42 onto the stack i32.const 42 ;; Push the i32 value 101 onto the stack i32.const 101 ;; Pop the last two i32 values off the stack, add ;; them both together and push the result onto ;; the stack i32.add ;; The stack contains an i32 with the value 143
As you can see, adding two numbers together requires more work than most other programming languages.
We are using the stack to hold the parameters that the i32.add
instruction will use.
In the example above we have pushed two constants onto the stack (42 and 101).
The instruction pops those i32
values off the stack, adds them together, and then pushes the result onto the stack.
The order you push the values onto the stack does not matter, the result will always be the same. However, with integer numbers you need to be aware of their limits.
;; Push the two i32 values onto the stack i32.const 4294967295 i32.const 2 ;; Add them together i32.add ;; The stack contains an i32 with the value 1
Because the first number is very close to the limit, and adding more to it so that it goes over the limit, makes the integer value overflow back around and restart at zero again. The end result is not mathematically correct.
To multiply two i32
numbers together you use the i32.mul
instruction.
There is an instruction to do the same thing for the different data types, i64.mul
, f32.mul
and f64.mul
.
;; Push the i32 value 42 onto the stack i32.const 42 ;; Push the i32 value 3 onto the stack i32.const 3 ;; Pop the last two i32 values off the stack, multiple ;; them both together and push the result onto ;; the stack i32.mul ;; The stack contains an i32 with the value 126
The process is similar to adding two numbers together, requiring you to use the stack to hold the two parameters before calling the i32.mul
instruction.
The above example shows two constants being pushed onto the stack (42 and 3).
These are popped off the stack, multiplied together, and the result is pushed onto the stack.
You can place the values onto the stack in any order. The result will be the same no matter which way round it is done. Again, when using integers, you need to know about their limits.
;; Push the two i32 values onto the stack i32.const 3724541951 i32.const 12345 ;; Multiple them together i32.add ;; The stack contains an i32 with the value 1845481415 ;; instead of 45979470385095
When you multiply both numbers together the end result is larger than the maximum value of a 32 bit integer value. The result is a number that gets truncated into the wrong value (mathematically speaking).
To subtract one number from another you use the i32.sub
instruction.
You can also use the i64.sub
, f32.sub
and the f64.sub
instructions for the different data types.
;; Push the i32 value 101 onto the stack i32.const 101 ;; Push the i32 value 42 onto the stack i32.const 42 ;; Pop the last two i32 values off the stack, subtract the ;; top most from the previous one and push the result onto ;; the stack, result = 101 - 42 i32.sub ;; The stack contains an i32 with the value 59
The instruction needs the two numbers to be pushed onto the stack, keeping in mind that the order you do this is important. The second number (42) is being subtracted from the first number (101). Doing this the other way round would give a different result.
101 - 42 = 59 decimal = 0x3B hexadecimal 42 - 101 = -59 decimal = FFFFFFC5 hexadecimal
With integers the numbers are treated like signed numbers. So negative amounts can be used.
When using floating point numbers, f32
and f64
, you can divide one number by another using
the f32.div
and f64.div
instructions.
Things are a little different when using integers and we will get back to that a little later.
;; Push the f64 value 3.142 onto the stack f64.const 3.142 ;; Push the f64 value 2 onto the stack f64.const 2 ;; Pop the last two f64 values off the stack, divide the ;; top most by the previous one and push the result onto ;; the stack, result = 3.142 / 2 f64.div ;; The stack contains a f64 with the value 1.571
To perform the instruction you need to first push the two values onto the stack. However, you need to be careful what order you do it in. You are dividing the first number (3.142) by the second number (2). Doing this the other way round would give a different result.
3.142 / 2 = 1.571 decimal 2 / 3.142 = 0.636537 decimal
Dividing integers is done a little differently.
An integer can be seen as signed (can be negative) or unsigned (positive only).
As a result there are two instructions you can use, i32.div_s
(for signed) and i32.div_u
(for unsigned).
;; Push the i32 value onto the stack i32.const 0xF178CD56 i32.const 2 ;; Divide 0xF178CD56 by 2 seeing it as signed ;; result = -243741354 / 2 = -121870677 i32.div_s ;; Push the i32 value onto the stack i32.const 0xF178CD56 i32.const 2 ;; Divide 0xF178CD56 by 2 seeing it as unsigned ;; result = 4051225942 / 2 = 2025612971 i32.div_u
In the above example we are dividing the same number by 2, but in the first case we are looking at the value as being signed, as a negative number. In the second part we are looking at the number as unsigned, as positive only. The end results are both different. You need to be careful how you are treating the integers being used, either as signed or unsigned.
When you divide one integer by another you may have some amount left over.
This is the remaining amount, the remainder (or modulus), for example, 10 / 3 is 3, thats (3 * 3) = 9 with 1 remaining.
To calculate the remaining amount you use the i32.rem_s
and i32.rem_u
instructions.
There are some for the i64
data type, but not for f32
or f64
.
;; Push the i32 value 10 onto the stack i32.const 10 ;; Push the i32 value 3 onto the stack i32.const 3 ;; Pop the last two i32 values off the stack, find the remainder ;; of dividing the top most by the previous one and push the result ;; on to the stack, result = 10 % 3 i32.rem_s ;; The stack contains an i32 with the value 1
The instruction needs to be performed by pushing the two values onto the stack. Take care though, you need to make sure the order is correct. You are dividing the first number (10) by the second number (3) and getting the remainder. You will get different results if you do it the other way round.
10 % 3 = 1 3 % 10 = 3
The above example used the signed instruction. This means it works differently with negative numbers. There is an unsigned instruction that treats the 32 bit number as a positive number only. You need to make sure you are using the right one otherwise you could get a result you didn't want.
;; Push the i32 values onto the stack i32.const 0xF178CD56 i32.const 42 ;; Get remainder from 0xF178CD56 by 42 seeing it as signed ;; result = -243741354 % 42 = -24 i32.rem_s ;; Push the i32 values onto the stack i32.const 0xF178CD56 i32.const 42 ;; Get remainder from 0xF178CD56 by 42 seeing it as unsigned ;; result = 4051225942 % 42 = 22 i32.rem_u
In the example above we are looking for the remainder of dividing the same number by 42, with the first case looking at it as a signed number (a negative value). For the second case the number is being looked at as unsigned (positive only). You end up with two different results. Take care how you look at the integers, seeing them as either signed or unsigned.
The f32
and f64
data types have a number of other math related instructions to help you manipulate floating point numbers.
Absolute | Converts a number into a positive only one. If the value is negative then it gets negated into a positive one. If the value is already positive then nothing is changed. |
Negate | Swaps the sign of the number. If the value is negative then it is changed into a positive one. If the value is positive then it gets changed into a negative one. |
Ceiling | Rounds up a number to the next whole number. For example, 3.142 is rounded up to 4.0. |
Floor | Rounds down a number to the previous whole number. For example, 3.142 is rounded down to 3.0. |
Nearest | Rounds a number to the nearest whole number. If the value is 0.5 or above it gets rounded up, otherwise it gets downed down. |
Truncate |
Removes the decimal points from a number, leaving the whole number parts only.
This is similar to Floor but it works differently with negative numbers.
|
Square Root | Calculates the square root of a number. |
Minimum | Works out the smallest of two numbers. |
Maximum | Works out the largest of two numbers. |
Copy Sign | Copies the sign part of one number and gives it to another. |
If you want to find out more about any of these instructions then take a look at them in the reference section.
You can convert the values from one type into another, but there are limitations and a number of different things to take into account. There are many instructions involved, too many to cover here, but I will go into the important ones and look at the details you need to be aware of.
You can only copy the lower half of a 64 bit integer into a 32 bit integer, so there will be some loss of data.
This is done using the i32.wrap_i64
instruction.
;; Push the i64 value onto the stack i64.const 0x9349C4FC0A12BC62 ;; This instruction pops the last item off the stack, converts it into an ;; i32 number, and pushes the result onto the stack. Result = 0x0A12BC62 i32.wrap_i64
This takes the i64
number off the stack, converts it into an i32
number, and pushes it onto the stack.
You will see that the higher part of the 64 bit number is gone.
Copying an i32
number into an i64
type number can be done in two ways.
You can treat the number as signed (can be negative) or unsigned.
You use either the i64.extend_i32_s
or i64.extend_i32_u
instructions.
If the i32
number has its most significant bit set to 1 then it can be seen as being a negative number.
Therefore when we convert it into an i64
type number, it also needs to become a negative number.
;; Push the i32 value onto the stack i32.const 0xFFFFFFFF ;; This instruction pops the last item off the stack, converts it into an ;; i64 number, seeing it as a signed number, and pushes the result onto ;; the stack. Result = 0xFFFFFFFFFFFFFFFF i64.extend_i32_s ;; Push the i32 value onto the stack i32.const 0xFFFFFFFF ;; This instruction pops the last item off the stack, converts it into an ;; i64 number, seeing it as an unsigned number, and pushes the result onto ;; the stack. Result = 0x00000000FFFFFFFF i64.extend_i32_u
This takes the same i32
number and converts it into an i64
data type.
The first time this is done it sees the number as a signed value (-1) and when converting it into a 64 bit number that also becomes -1.
You will notice that all the bits are 1, not just the lower 32 bits.
The second time we are looking at the number as unsigned, as a positive number only, and therefore the higher part of the 64 bit number is set to zero,
while the lower part matches the starting 32 bit number.
You can only convert the whole number part of a floating point number, with everything after the decimal point being discarded.
It also needs to be within the range of values an integer can contain.
This depends on whether the integer is seen as signed (can be negative) or unsigned (positive value only).
This is done using the i32.trunc_f64_s
and i32.trunc_f64_u
instructions.
;; Push the f64 value onto the stack f64.const -123.456 ;; This instruction pops the last item off the stack, converts it into an ;; i32 number, seeing it as a signed number, and pushes the result onto ;; the stack. Result = -123, 0xFFFFFF85 i32.trunc_f64_s ;; Push the f64 value onto the stack f64.const 123.456 ;; This instruction pops the last item off the stack, converts it into an ;; i32 number, seeing it as an unsigned number, and pushes the result onto ;; the stack. Result = 123, 0x0000007B i32.trunc_f64_u
The first part is taking the negative floating point number, converting it into a 32 bit number, one that is being viewed as a signed number. This will create a negative integer value. The second part is converting a positive number and treating it as unsigned.
If you try to convert a negative floating point number into an unsigned integer then you will get a runtime error. This is because the number was not within the range of an unsigned integer. You will also get this error if the number is too large to fit.
These limits can be handled by using the i32.trunc_sat_f64_s
and i32.trunc_sat_f64_u
instructions.
If the number goes over the limit range then instead of throwing a runtime error, it converts the number to the maximum range value.
For example, if the floating point number is -123.45 and we convert it unsigned, then instead of throwing an error, the result is set to zero, as that is the range limit.
Converting an integer value into a floating point number can be done using the f64.convert_i32_s
and f64.convert_i32_s
instructions.
Because the integer can be seen as either signed (can be negative) or unsigned (positive only), there are two different instructions to handle both.
;; Push the i32 value onto the stack i32.const 0xFFFFFFFF ;; This instruction pops the last item off the stack, converts it into ;; a f64 number, seeing it as a signed number, and pushes the result ;; onto the stack. Result = -1.0 f64.convert_i32_s ;; Push the i32 value onto the stack i32.const 0xFFFFFFFF ;; This instruction pops the last item off the stack, converts it into ;; a f64 number, seeing it as an unsigned number, and pushes the result ;; onto the stack. Result = 4294967295.0 f64.convert_i32_u
We are converting the same 32 bit integer value, however, the first time we are seeing the value as a signed value, -1 in this case. Afterwards we are viewing the number as unsigned, with the value of 4294967295. The end result is different in both cases.