Swift. Numbers. And their limits (Part 1)

All We Have

Swift provides a variety of number types: Int, Float, Double and others.

Each of these types serves a distinct purpose, offering storage options for different kinds of numeric data.

Signed Integers

With 64-bit systems now making up more than 98% of active Apple devices, we can typically think of Int as Int64. This means the storable value range is enormous:

-92233720368547758089223372036854775807
minus nine quintillion, two hundred twenty-three quadrillion, three hundred seventy-two trillion, thirty-six billion, eight hundred fifty-four million, seven hundred seventy-five thousand, eight hundred eightnine quintillion, two hundred twenty-three quadrillion, three hundred seventy-two trillion, thirty-six billion, eight hundred fifty-four million, seven hundred seventy-five thousand, eight hundred seven

These numbers are so large that it’s challenging to grasp their magnitude. For example:

  • 1 trillion is roughly the number of stars in our galaxy.

  • The universe is about 473 quadrillion seconds old.

And still we can use 9000 quadrillion with Int. So we can use it for every whole number amount on anything on Earth that is enough for any reasonable application.

We even can use it as unique id, safely creating hundreds of thousands instances per second without any problem for thousands of years.

Yet, Int64 can handle values up to 9,000 quadrillion. This means you can count virtually everything on Earth—every building, car, person, or animal—using just Int64.

Moreover, you can even use Int64 as a unique identifier, safely generating hundreds of thousands of instances per second for thousands of years without exhausting the range.

However, even with all this power, there are limits. There are ways how you can ruin your day:

  1. Overflow: Int.max + 1

  2. Underflow: Int.min - 1

For simple code, the compiler can often figure out that these operations are not allowed:

But, of course, you can always "outsmart" it by using dynamic values that sneak past the compiler and crash your app at runtime.

Swift also provides opportunities predictable results in any situation so we can have code that ALWAYS works as expected:

  1. Overflow operators if we want that very specific bit-shifting behaviour

  2. Crash-safe operations:

Addition and subtraction works as expected, as we can logically conclude. When number goes over its top "limit" it starts again from its bottom and vice versa.

Results of division might look a bit strange at times, but that’s actually the intended behavior. When the division doesn’t cleanly produce a quotient, Swift simply returns the original divided value.

Now, let’s move on to multiplication, but I want to pause here to explain some important details before we go any further. When you multiply Int.max by 2, it definitely causes an overflow. Intuitively, you might expect the result to be -1 because we’re essentially adding another Int.max to the existing Int.max, which should wrap around from the bottom of the number range. However, the bottom part of the range (from Int.min to 0) is actually larger by 1, with that extra value used to represent zero.

Although, if you think so you forgot to spend 1 to move from Int.max to Int.min. Thinking about overflowing Int variable as circle where Int.max is connected to Int.min with distance of 1. It becomes clear that instead of adding Int.max to Int.min we need to add Int.max - 1 and since abs(Int.min) = Int.max - 1 final result of overflow will be -2.

To grasp what's happening, we need to dive into a fundamental question:

How do computers store numbers?

Let’s start with Int8. As the name suggests, Int8 uses 8 bits to represent its value. Since this integer type is signed, the first bit is reserved for indicating the sign: 0 for positive and 1 for negative. The remaining 7 bits are used to represent the actual value. Each of these bits can be either 0 or 1, and their position in the byte determines the power of the binary base (2) they represent. Here’s how it works:

Each bit's place value is summed to calculate the final number in the decimal (base-10) system. The maximum positive value of 127 is reached when all 7 bits are set to 1:

$$\begin{align} 1*2^7 + 1*2^6 + 1*2^5 +1*2^4 + 1*2^3 + 1*2^2 + 1*2^1 + 1*2^0\\ =64 + 32 + 16 + 8 + 4 + 2 + 1\\ = 127 \end{align}$$

In this way, Int8 allows us to represent 127 positive numbers and zero when the sign bit is 0. Additionally, it represents another 128 negative numbers when the sign bit is 1.

Now, let's explore an overflow situation using Int8, which is simpler to calculate and visualize. We'll multiply Int8.max by 2. Normally, multiplying a number by a power of 2 can be efficiently done using a left shift operation. So, let's see what happens:

01111111 -> 11111110

When we shift left, an additional zero appears on the right, and the leftmost 1 is discarded because we only have 8 bits available. This loss of the leftmost bit is exactly how overflow manifests in binary arithmetic.

The sign bit is 1, which indicates the number is negative. The remaining bits give us 126 in the decimal (base-10) system, so the result should be -126, right?

Not quite.

Computers use a method called "Two's complement" to represent negative numbers. This approach simplifies and speeds up arithmetic operations within the Arithmetic Logic Unit (ALU).

The process is straightforward. To convert a positive number to its negative counterpart (let’s use -5 as an example), follow these steps:

  1. Start with the positive number 5 (in binary: 00000101).

  2. Invert all the bits (resulting in 11111010).

  3. Add 1 to the inverted bits (resulting in 11111011).

Using this method, let’s determine the correct value of 2 * Int.max, which is represented by the binary 11111110:

  1. Initial binary: 11111110

  2. Invert the bits: 00000001

  3. Add 1: 00000010

  4. Result: 2 in decimal (base-10)

So, when we use this understanding of how computers store numbers, it becomes clear why Int.max.multipliedReportingOverflow(by: 2) results in -2.

Unsigned Integers

Swift provides the whole family of unsigned integers as well: UInt8, UInt16, UInt32, UInt64 and even beta UInt128(for some specific financial, cryptographic applications and etc).

Their main advantages are:

  1. Better programming intention and abstraction for items that can never be negative like: sold item quantity, number of siblings and similar. You can be sure that there is no operation that turns your value to negative side.

  2. Increased storage capacity by simple re-purposing first(sign) bit as another meaningful bit that allows 2x larger maximum number.

So, when we take our familiar 11111110 (which represents -2 in Int8), in UInt8 it becomes 250. This is because UInt8 treats all bits as part of a positive number range.

Understanding overflow with unsigned integers is straightforward if you think of UInt8 as a circle of numbers, where the values wrap around. The circle connects at the points between UInt8.min (0) and UInt8.max (255). When you overflow, you simply continue around the circle back to 0, and when you underflow, you wrap back around to 255.

Conclusion

Swift's integer types, both signed and unsigned, rely on binary representation and two's complement for handling negative values. Visualizing integers as circular ranges, where Int.max wraps around to Int.min (and similarly for UInt), simplifies predicting overflow behavior.

Next time we will explore floating-point types.

0
Subscribe to my newsletter

Read articles from Stanislav Kirichok directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Stanislav Kirichok
Stanislav Kirichok