Rust: Integers

Home · Blog

8 February 2023

Rust has built-in integer types in various sizes: from 8-bit to 128-bit, unsigned and signed, with two's complement semantics:

type    description             (approximate) range
i8      8-bit signed            -128 to 127
u8      8-bit unsigned          zero to 255
i16     16-bit signed           -32768 to 32767
u16     16-bit unsigned         zero to 65535
i32     32-bit signed           -2 billion to 2 billion
u32     32-bit unsigned         zero to 4 billion
i64     64-bit signed           numbers with 19 decimal digits
u64     64-bit unsigned         non-negative numbers with 20 digits
i128    128-bit signed          numbers with 37 decimal digits
u128    128-bit unsigned        non-negative numbers with 38 digits

(Not sure if that’s the best way to describe the ranges -- the numbers get pretty big!)

There’s two more integers whose size depends on the processor architecture that the program is compiled for. They’re the same size as a pointer:

type    description             (approximate) range
isize   pointer-size signed     usually same as i64
usize   pointer-size unsigned   usually same as u64

Any modern laptop, server, or smartphone will have a 64-bit processor, so those are usually 64-bit integers. The exception is when you’re writing embedded software -- maybe you’ve hacked your smart thermostat and you’re re-programming it in Rust -- then you may have a 32-bit or even 16-bit processor.

The usize type is used for things like indexing an array or vector. I’m not sure if isize is used much.

Integer literals

When you define a variable with one of the integer types, you can set the variable’s type:

let signed8: i8 = -33;
let unsigned64: u64 = 333333333333333333;
let pointer_size: usize = 123;

Or add a suffix to set the literal’s type:

let signed8 = -33i8;
let unsigned64 = 333333333333333333u64;
let pointer_size = 123usize;

You can add underscores to make big numbers easier to read, and define numbers in octal, hexadecimal, or binary notation with the 0x, 0o, and 0b prefixes:

let two_billion = 2_000_000_000;   // i32, underscores are ignored
let hex: u8 = 0xff;                // 255 in hexadecimal
let oct: u8 = 0o77;                // 63 in octal
let bin: u8 = 0b11;                // 3 in binary

If you don’t set a type, the default is i32, even with the hex/octal/binary notation. By the way, the old C syntax for octal numbers, prefixing the number with just the digit zero, is not used in Rust.

There’s one more format for integer literals: a byte literal defines a u8 with the ASCII value of a character:

let byte = b'A';   // u8 with value 65 (A is character 65 in ASCII)

Casting

Rust doesn’t convert from one integer type to another unless you tell it to. For example, this is not valid:

let unsigned16: u16 = 123u8;   // not allowed, types don't match

The as keywork is used for “type casting”, telling the compiler to change the type of an integer:

let x = 123u8 as u16;   // ok, x will be 123u16

For the simple cases, where the new type can represent the value, this does what you’d expect. If the new type is smaller, leading bits will just be discarded. When casting between between signed and unsigned, the bits of the integer will stay the same, only the type changes:

let a = 0xffffu16 as u8;   // ok, a will be 0xffu8
let b = 0xffu8 as i8;      // ok, b will be -1i8
let c = -1i8 as u8;        // ok, c will be 0xffu8

If that last part didn’t make sense to you, just remember that casting from a larger type to a smaller one or casting between signed and unsigned is dangerous because you’ll get strange results if a number doesn’t “fit into” the new type.

Parsing and formatting

To convert a string to a number, you call the parse method (on &str or String), which returns a Result:

let five: u32 = "5".parse().unwrap();
let minus_one: i64 = String::from("-1").parse().unwrap();
let oops = "hello".parse::<i32>();      // Err
let also_oops = "-1".parse::<u32>();    // Err
let still_oops = "256".parse::<u8>();   // Err

This is one of those methods whose return type depends on a type parameter, so you have to either make sure the compiler can figure out the type with type inference or set it with the turbofish operator. As you can see in the last two, it’ll correctly handle the case where the string is a valid number but can’t be represented with the given type.

To go the other way around and turn an integer into a string, use the format! macro. It uses the same mini-language as the print! and println! ones:

let n = 42;
let decimal = format!("{}", n);    // "42"
let hex = format!("{:x}", n);      // "2a"
let oct = format!("{:o}", n);      // "52"
let bin = format!("{:b}", n);      // "101010"
let padded = format!("{:4}", n);   // "  42"

See std::fmt for the details.

Overflow

What should happen when the result of a calculation is outside the range of its type? For example, 255 is the largest value that a u8 can represent, so what should this print?

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

Different languages answer that question in different ways. In Python, integers have unlimited size, so this never becomes a problem; in C and C++ integer overflow is undefined behavior; in Java and Go the numbers defined to “wrap around” according to two’s-complement arithmetic. Rust uses what seems to me an awkward compromise: integer overflow wraps around like in Java and Go, but also it’s an error.

There’s three different cases:

  1. If overflow can be detected at compile time, it’s a compile error. That’s what would happen for the example above.
  2. Otherwise, if you’re building in debug mode, the overflow will be detected at runtime and the program will panic.
  3. If you’re building in release mode, overflow won’t be detected and the numbers will wrap around.

If you want more precise control over what happens in case of overflow, the integer types each have a set of methods that lets you select the behavior: wrapping so numbers wrap around according to two’s-complement arithmetic, checked to get an Option with None in case of overflow, overflowing to get a tuple with the result and a boolean that indicates whether overflow occurred, and saturating for calculations that produce the highest value possible instead of overflowing. Here’s all of them in action:

let x: u8 = 255;
let y: u8 = 1;
println!("{}", x.wrapping_add(y));        // prints 0
println!("{:?}", x.checked_add(y));       // prints None
println!("{:?}", x.overflowing_add(y));   // prints (0, true)
println!("{}", x.saturating_add(y));      // prints 255

All the examples so far were about going higher than the maximum value of a type; going lower than the minimum works the same way. For example, this prints -128, which is the lowest value for i8:

let x: i8 = -100;
let y: i8 = 50;
println!("{}", x.saturating_sub(y));