Operators of death: checked arithmetic in Rust

Operators of death: checked arithmetic in Rust

 

“Kill the humans… I must kill… the humans…”

 

Everything has its limits, even Rust, and this applies to numeric types too. They always have a fixed width—a fixed number of bits—and that means there’s a limit to how large a number they can represent. When the result of a sum is bigger than that maximum value for the type, we can no longer represent it correctly: a problem called overflow.

Spells of protection from arithmetic overflow

You probably know that numbers (and everything else) are represented internally in the computer using binary bits. Rust’s various numeric data types each have a fixed number of bits with which to represent a number: for example, the u8 type represents a number using 8 bits. That’s because the compiler needs to know how much memory space to reserve for each of the data values in your program.

But just as there’s only so large a number you can write down in decimal on a fixed-width piece of paper, there’s only so large a number you can store as a Rust value using a fixed number of bits. Let’s see if we can figure out what that number is.

Thinking in bits

The relationship between the width of a data type—the number of bits it uses to store a value—and the range of values it can represent is straightforward, but let’s think our way into it a little.

Suppose we had only one bit available to represent numbers. That would be tough, but we could still do something, and it’s the easiest case to start with.

A single binary bit can take one of two possible values (hence “binary”). It’s up to us what names we use for these values: “true or false”, “on or off”, “set or clear”, “high or low” are all used in various contexts. But for this discussion let’s call them 0 or 1.

How many different number values can we represent with a 1-bit type? The answer is two. Easy, right? The choice of which decimal numbers these two values represent is purely arbitrary. It doesn’t have to be 0 and 1, but let’s go with that for simplicity.

Now suppose we widen our type a little, to two bits. How many possible values can we represent now? Maybe you see the answer right away, but let’s write out all the possibilities to be clear:

Bit pattern Decimal
00 0
01 1
10 2
11 3

Again, the precise decimal numbers that we assign to each permutation of the bits are an arbitrary choice, and we might choose differently depending on the situation.

With the scheme we’ve outlined so far, we can only represent zero or positive numbers (called unsigned representation), but that’s okay; there are other schemes. Let’s see how this one changes as we add more bits to the representation.

Widening the type

How about a three-bit type? Can you say what the cardinality—the number of possible values—is now? If you guessed “six”, I totally see where you’re coming from, but think about it like this: a one-digit decimal number can represent 10 values, while a two-digit decimal number can represent 100 values. Each extra digit we add doesn’t add ten to the cardinality, it multiplies it by ten, because the new digit creates ten possible variations of each existing number.

And it’s the same principle here: each binary digit we add multiplies the cardinality by two, because that digit can take two values. For example, you could take each of the four 2-bit values from the previous table, and put a “0” bit in front of them, and then you could take them all again and put a “1” bit in front. Thus, the extra bit gives us twice the possibilities: that’s 8 values.

How many numbers can we represent using 8 bits, then—that is, in a Rust u8? With one bit, we had 2 possibilities, with two bits, 4, and with three, 8, doubling with each extra bit. So we’ll continue, muttering under our breath, “four bits gives 16, five bits gives 32, six bits gives 64, seven bits gives 128, and eight bits gives 256.”

Okay, so we have 256 possible bit patterns, and if we interpret them as unsigned decimal numbers (“u8” means “unsigned, 8 bits”), each one can represent a value between 0 and 255 inclusive.

Obvious question, then: what happens if we try to put a number bigger than 255 into a u8?

Oh no, overflow

Let’s write a simple Rust program where we do just that. For example:

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

If we have a u8 variable containing 255, and we try to add 1 to it, then we feel convinced that something interesting should happen. Let’s see what:

cargo run

error: this arithmetic operation will overflow
 --> src/main.rs:3:20
  |
3 |     println!("{}", val + 1);
  |                    ^^^^^^^ attempt to compute `255u8 + 1_u8`, 
  | which would overflow
  |
  = note: `#[deny(arithmetic_overflow)]` on by default

Rust won’t let us even build this program, because it’s clear at compile time that the result of the sum won’t fit in 8 bits. That’s helpful, but Rust can’t protect us from every instance of this kind of mistake. It’s not always so clear to the compiler that overflow will happen.

Would you say it’s time to panic?

For example, instead of adding a constant, suppose we use a loop to add increasingly larger numbers to some starting value:

fn main() {
    let val: u8 = 250;
    for i in 1..7 {
        let answer = val + i;
        println!(" +  = ");
    }
}

(Listing overflow)

We can see that this will soon exceed 255, but I think we’ll be able to sneak that fact past Rust. Let’s find out:

cargo run --example overflow

Here’s the output:

250 + 1 = 251
250 + 2 = 252
250 + 3 = 253
250 + 4 = 254
250 + 5 = 255

thread 'main' panicked at examples/overflow.rs:4:42:
attempt to add with overflow

Okay. Rust couldn’t catch the problem at build time in this case, but it caught it at run time, which is still better than nothing. At least we didn’t get the wrong answer.

Hang on, though. By default, cargo run builds our program using the dev profile, which behaves slightly differently to the release profile. Let’s try it with release instead to see what happens:

cargo run --profile release --example overflow

250 + 1 = 251
250 + 2 = 252
250 + 3 = 253
250 + 4 = 254
250 + 5 = 255
250 + 6 = 0

Oh no! It seems like Rust can’t do this simple sum correctly. Before you apply to the Rust Foundation for a refund, though, bear in mind this is documented behaviour, even if it may not be what we intended.

Rust only promises to check for overflow in dev builds. In release builds, these checks are disabled, and the overflowing value wraps: that is, it starts again at the lowest possible value, which in this case is zero.

I care about this because…?

Wrapping an overflowed result is a pragmatic policy, but not always the right one. Imagine yourself on the treatment table of a radiation therapy machine, for example. And let’s say a subtle bug in the dose-calculation software leads to an arithmetic overflow.

Now, what would you prefer to happen next? Choose from one of the following:

  1. The machine stops with an error, requiring the operator to reset it.
  2. The machine gives you an incorrect, fatal dose of radiation.

I can’t imagine any programmer would rather kill the patient than see an error message but, surprisingly, that’s Rust’s default behaviour in this case. (Ironically, my computer crashed right while I was typing that sentence. Food for thought next time you’re trusting your life to someone else’s software.)

Programmers have a term for features like this that make sense in context, but are also easy to misuse or misunderstand, and dangerous when you do: they’re called footguns. In other words, they make it possible to shoot yourself in the foot.

The fact that numbers wrap around in Rust when the + operator overflows is just such a footgun. What can we do to avoid a negligent discharge, then? We could try to avoid doing arithmetic at all in our programs, but that’s rather limiting. Is there a better option?

Setting overflow-checks for release builds

As we’ve seen, with the dev profile Rust will catch overflows for us and stop the program. If you want overflows to be checked with release too, or with any other build profile you define, you can configure the overflow-checks setting in your Cargo.toml:

[profile.release]
overflow-checks = true

This is the quickest and simplest way to avoid overflow in production, but it’s not necessarily the best way. For one thing, it’s not clear to someone reading your program that overflows will be checked in release mode, if they don’t happen to also glance at your Cargo.toml. It’s not the default behaviour, so readers won’t be expecting it.

Also, overflow checks aren’t free. They add significant extra size and run time to the compiled program, and we don’t always need them, so it’s a shame to pay this performance penalty for all arithmetic operations when we don’t have to.

By the way, it’s not only the + operator you need to watch out for. The * operator can overflow too, since multiplication is just repeated addition.

A similar problem can happen with the - operator: if the result of the subtraction is less than the minimum value for the type, it’ll wrap back around to the maximum value (this is called underflow, but it’s the same problem: we ran out of bits).

The /, % (remainder), << (shift left) and >> (shift right) operators can also overflow on integer types. That’s a whole arsenal of footguns waiting to give us (or our users) a nasty surprise.

The operators… of death

Assuming we care about getting our sums right, then, what should we do about this sort of thing? Take a firm grip on your web browser, because I’m about to drop a controversial hot take:

We should avoid using the + operator in Rust.

While you’re still reeling from that, I think we should avoid the -, *, /, %, << and >> operators too. No matter how wide the data type we use with them, these operators can cause overflow and thus lead to unexpected or non-obvious behaviour.

How on earth are we supposed to do arithmetic in Rust if we shouldn’t use these operators? Well, we have other ways of doing arithmetic. One of the major things Rust does to help us write correct programs is to give us ways to be more explicit about their behaviour.

Arithmetic is a good example of this, because it turns out we can choose exactly the overflow behaviour we want for any particular sum, regardless of the build mode. Let’s go through the available choices one by one.

The checked_add method

checked_add returns an Option, which is Rust’s usual way of indicating that there may or may not be a value here.

In this case, if everything’s okay, we’ll get Some(answer), but if the result of the addition overflowed the type, we’ll get None instead:

print!(" +  = ");
if let Some(answer) = val.checked_add(i) {
    println!("");
} else {
    println!("oh no, overflow!");
}

(Listing checked-add)

This is almost certainly what we want in most cases. We’re saying that we don’t expect this sum to overflow, but it might. If it does, the answer will be invalid , so we don’t want it (we’ll get None). But if everything goes according to plan, we’ll get Some(answer), and that tells us answer is valid: there was no overflow.

Here are the checked-arithmetic equivalents for the other operators:

Operator Checked equivalent
- checked_sub
* checked_mul
/ checked_div
% checked_rem
<< checked_shl
>> checked_shr

And there are many more checked_X methods for other arithmetic operations; have a look at the documentation for u8, for example, to see the full list.

The overflowing_add method

Sometimes, more rarely, we expect that the sum may overflow, and if it does, the answer isn’t necessarily invalid. All the same, we need to know whether overflow has happened, because it may mean that we treat the answer differently.

In this case, we can use overflowing_add. Instead of an Option, this returns a tuple of two elements: the answer (wrapped or not), and a bool value that tells us whether overflow occurred:

let (answer, overflow) = val.overflowing_add(i);
println!(
    " +  =  {}",
    if overflow {
        "(overflowed and wrapped)"
    } else {
        "(no overflow)"
    }
);

(Listing overflowing-add)

Again, there are overflowing_X equivalents for the other operators, and the same is true for all the methods we talk about here.

The wrapping_add method

Sometimes we always want the number to wrap on overflow, and we don’t need to know whether or not this happened. That’s what the wrapping_add method gives us:

let answer = val.wrapping_add(i);

(Listing wrapping-add)

Using wrapping_add makes sense when we want the answer to wrap around to its lowest value once it goes past its highest possible value. That’s rare, but there are cases where it’s useful: some kinds of counter or timer use this behaviour, for example.

Making the wrapping explicit helps the reader understand that overflow is expected here, and that it’s the correct behaviour for this particular sum.

The saturating_add method

Sometimes, rather than wrap the value or return None, we want to constrain the result to the maximum value allowed for the type. That’s the behaviour we get from the saturating_add method:

let answer = val.saturating_add(i);

(Listing saturating-add)

With our overflow-testing example, this gives:

250 + 6 = 255

Obviously this is the wrong answer to this particular sum, but by using saturating_add we’re saying that we want either the right answer, or the maximum value for a u8, if the right answer would overflow 8 bits.

For example, suppose our program controls a spacecraft, and its rocket thrust setting is represented as a u8, meaning that the allowable range is from 0 to 255.

If we’re already at 255—that is, at maximum thrust—and the user presses the “go faster” button again, that’s not an error, but we definitely don’t want the thrust setting to suddenly wrap around to zero! That would give the user a nasty case of whiplash.

The thrust shouldn’t change at all in this case, since it’s already at maximum. To achieve that, we might use saturating_add to clamp the output to 255 without wrapping.

The strict_add method

Sometimes we want to say that a particular sum should never overflow in any circumstances, and if that does happen, things have gone so disastrously wrong that the safest course of action is to stop the program (that is, panic). As we’ve seen, that’s what Rust already does implicitly, in dev builds, if we’re using the regular arithmetic operators.

To specify this “panic on overflow” behaviour explicitly, though, we have the strict_add method:

let answer = val.strict_add(i);

(Listing strict-add)

In other words, we’re saying that the design of the program should have ensured that overflow never happens, and the fact that it has indicates an unrecoverable bug.

In general, our Rust programs should never panic unless this is the case, but they should always panic when it is the case. strict_add is the spell we need to ensure that happens.

Choose your own arithmetic adventure

It’s important to be aware of Rust’s default responses to overflow with the traditional arithmetic operators, so let’s tabulate them:

Profile Overflow result Equivalent to
dev Panic strict_add
release Wrap wrapping_add

I prefer using explicit arithmetic methods instead: this avoids potential confusion, and makes it clear to readers what will happen if a calculation overflows. To me, this seems very much in the spirit of Rust.

To summarise:

Method on T Returns Overflow result Makes sense when
checked_add Option<T> None We only want the answer if it’s not wrapped
overflowing_add (T, bool) (Wrapped answer, true) We treat the answer differently if it’s wrapped
wrapping_add T Wrapped answer Wrapping is explicitly okay
saturating_add T Answer or T::MAX, whichever is lower We want the answer clamped to T::MAX
strict_add T Panic Wrapping indicates a bug

If you want to make sure that you don’t accidentally use one of the forbidden operators—and we all have moments of weakness—there’s a Clippy lint to remind you:

#![deny(clippy::arithmetic_side_effects)]

(I’m trying to get this renamed to clippy::operators_of_death, but you’d be surprised how stuffy some people can be about this sort of thing.)

It can sometimes take a bit of extra thought to eliminate unchecked arithmetic in your Rust programs (“What do I really want to happen here? Is it okay to wrap? Or should this be an error?”). It’s worthwhile, though, as unhandled overflow can lead to all sorts of unpleasant bugs, including security issues.

In conclusion, if you don’t want to get your bits chopped off—and it’s as painful as it sounds—consider using Rust’s explicit checked arithmetic methods instead of the operators of death.

You can read more valuable medical advice about protecting the health of your programs (and your users) in The Rust Spellbook. Side effects may include better code, a deeper understanding of Rust, and a compulsion to tell people all the interesting stuff you learned. Ask your doctor if the Rust Spellbook is right for you.

Welcome to the machine: emulating a CPU

Welcome to the machine: emulating a CPU

0