Handling Integer Overflow in Rust

28 January 2023

In perhaps a majority of cases, integer overflow is not intended. It’s easy to find many instances of integer over and underflow causing unexpected behaviour in software - ranging from the amusing -127 lives glitch in Super Mario Bros., to a frightening bug in the software of the Boeing 787. In the hope of preventing these issues, the Rust compiler performs analysis on code to identify cases of potentially unintended integer overflow, while also offering a few different ways for programmers to explicitly allow overflow as desired - this is what we will explore in this article.

The Default Behaviour

Consider a minimal Rust application which causes integer overflow:

fn main() {
    let x: u8 = 255 + 1;
    println!("{}", x);
}

I’m sure most would expect an output of 0 from this program. Let’s verify that:

$ cargo run

error: this arithmetic operation will overflow
 --> src/main.rs:2:17
  |
2 |     let x: u8 = 255 + 1;
  |                 ^^^^^^^ attempt to compute `u8::MAX + 1_u8`, which would overflow
  |
  = note: `#[deny(arithmetic_overflow)]` on by default

error: could not compile `overflow-example` due to previous error

Oh, nevermind! rustc clearly is not willing to compile such ambiguous code.

This is an example of one of the many static checks the Rust compiler performs on any code it compiles. Taking a closer look at the error message, we are informed that this check is enabled due to #[deny(arithmetic_overflow)] being on by default. This would imply we can disable this check by adding an allow annotation to the offending line. Let’s try that:

fn main() {
    #[allow(arithmetic_overflow)]
    let x: u8 = 255 + 1;
    println!("{}", x);
}

What happens now when this modified program is executed is determined by the profile we compile it with. For those unaware, a profile is simply a set of options that are taken into consideration when compiling a Rust program (e.g., the optimisation level to use). Cargo defines out of the box two build profiles for standard executable programs - dev and release. The former is used when you run the command cargo build while the latter is used when the --release option is introduced. Let’s first try out the ‘release’ profile:

$ cargo run --release

   Compiling overflow-example v0.1.0 (/tmp/overflow-example)
    Finished release [optimized] target(s) in 0.50s
     Running `target/release/overflow-example`
0

Hooray! The behaviour you were probably initially expecting. Let’s try the dev profile next:

$ cargo run

    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/overflow-example`
thread 'main' panicked at 'attempt to add with overflow', src/main.rs:3:17
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

And once again, Rust is unhappy with us. This time however the failure is occurring at runtime. The reason for this is due to a difference in the definitions of the dev and release profiles which can be seen here and here respectively. These differ in many of the ways you would expect (e.g., optimisation level, the enabling of debug assertions, etc.), but the relevant option is the aptly-named option overflow-checks which is enabled in dev and disable in release - this explains the runtime panic above.

If you’re developing an application which in some way relies on integer overflow occurring, then you could potentially add the following lines to your Cargo.toml to ensure overflow-checks is disabled even when building with dev:

[profile.dev]
overflow-checks = false

I would however not advise doing this. Instead, let’s explore the options Rust provides to explicitly handle integer overflow in code itself.

Explicit Arithmetic Functions

Rust provides four different groups of functions on all signed and unsigned integer types, allowing for integer overflow to be handled in different ways. The first variety we’ll look at is the wrapping_ family of functions:

(250_u8).wrapping_add(10);     // 4
(120_i8).wrapping_add(10);     // -126
(300_u16).wrapping_mul(800);   // 43392
(-100_i8).wrapping_sub(100);   // 56
(8000_i32).wrapping_pow(5000); // 640000

These examples should hopefully make it clear that the wrapping_ functions handle integer overflow by simply wrapping back around from the maximum value of the given integer type to the minimum (i.e., what one would expect to happen by default). This approach ensures that there is no risk of an unexpected panic occurring when using these functions regardless of the build profile. While the syntax is verbose, this is arguably an advantage as it makes it very clear to readers of the code that these values have the potential to overflow and that it is expected that values will wrap around when that happens.

A slight variant on the above set of functions are the overflowing_ functions:

let (result, overflowed) = (250_u8).overflowing_add(10); // 4, true
println!("sum is {} where overflow {} occur", result, if overflowed { "did" } else { "did not" });

These functions are equivalent to the wrapping_ functions except they also return a Boolean value which indicates whether or not overflow occurred. This may be particularly useful when implementing emulators for example as many CPUs have a flag that must be set whenever an instruction causes overflow.

Perhaps instead of wrapping values, we want to handle overflow as a special case. This can be done using the checked_ functions as follows:

match (100_u8).checked_add(200) {
    Some(result) => println!("{result}"),
    None => panic!("overflowed!"),
}

There is also the option of not wrapping and instead ‘saturating’ (when the maximum or minimum value for a given integer type is reached, just remain at that value instead of wrapping around).

(-32768_i16).saturating_sub(10); // -32768
(200_u8).saturating_add(100);    // 255

Overhead?

It would be natural to worry that initiating a function call every time we want to perform basic arithmetic may negatively impact the execution speed of our code. Fortunately, there is nothing to fear as Rust is clever enough to optimise away any actual function calls. We can prove this by using cargo-show-asm (not to be confused with cargo-asm which is no longer maintained - thanks to /u/manpacket on Reddit for pointing this out) to see the assembly code that a given function gets compiled to.

Let’s define a function that simply adds two numbers and then take a look at the generated assembly:

pub fn addition(x: u8, y: u8) -> u8 {
    x + y
}
$ cargo asm overflow_example::addition --simplify

    Finished release [optimized] target(s) in 0.00s

overflow_example::addition:

	lea eax, [rsi + rdi]
	ret

A single lea instruction. Now lets try swapping to wrapping_add:

pub fn addition(x: u8, y: u8) -> u8 {
    x.wrapping_add(y)
}
$ cargo asm overflow_example::addition --simplify

    Finished release [optimized] target(s) in 0.00s

overflow_example::addition:

	lea eax, [rsi + rdi]
	ret

And as we hoped, the assembly produced is identical - the call to wrapping_add has been optimised away.

Wrapper Types

If there are numerous places in a given code base where overflow may occur then the above method will quickly become quite verbose and perhaps difficult to work with. It also entails the risk of missing an instance where overflow could occur, and thus risking a runtime panic (depending on the build profile). Fortunately, Rust offers a solution in the form of the type Wrapping<T>. This type allow the normal arithmetic operators (+, /, etc.) to be used while ensuring values are automatically wrapped around whenever integer overflow occurs. Let’s try it out:

use std::num::Wrapping;

let mut x = Wrapping(125_u8);

x + Wrapping(200); // 69
x - Wrapping(200); // 181
x *= 5; // if we mutate the variable x then we can use primitive integer types - x is now 113

x / 5; // error! careful - we can only use primitives when we're assigning (i.e., using +=, -=, etc.)

This is clearly more succinct than the alternative of placing calls to wrapping_ functions all over the place.

There is also Saturating<T> which works in much the same way as Wrapping<T> except values become saturated when overflow occurs. At the time of writing, this type is a nightly-only experimental feature but it will presumably be merged into stable at some point in the future. For the time being, the saturating_ functions are of course still an option!