Rust Number Types Explained

A number in Rust can either be an integer or a float. Numbers without fractional components are integers, while numbers with fractional components are called floats, which is short for a floating point number.

Integers in Rust may either be signed or unsigned. Signed integers as the name implies are integers that carry a sign. They can be negative, positive, or zero. Unsigned integers on the other hand are integers without a sign and are either positive or zero.

Rust number types

Signed and Unsigned Integers

Signed integers are labeled with an i followed by the size of the integer specified in bits. Unsigned integers are labeled with a u followed by the size of the integer.

        //integers: signed / unsigned integers

//signed
let x: i8 = 2;

let y: i16 = -3;

let z: i32 = 0;

//unsigned
let a: u8 = 2;

let b: u16 = 3;

let c: u32 = 0;
      

We can specify the size and type of the integer by adding the label at the end of the number literal.

        //integers: signed / unsigned integers

//signed
let x = 2_i8; // => i8

let y = -3_i16; // => i16

let z = 0_i32; // => i32

//unsigned
let a = 2_u8; // => u8

let b = 3_u16; // => u16

let c = 0_u32; // => u32
      

If we don’t specify the type of integer, the variable is given a signed 32-bit integer by default.

        let x = 2; // => i32

let y = -3; // => i32

let z = 0; // => i32
      

There are 12 variants of the integer types in Rust and they are classified by sign and size. We have 8, 16, 32, 64, and 128-bit integers, which can be either signed or unsigned.

Rust integer types

The isize and usize variants, their sizes depend on the architecture of the computer on which the program is running. For 32-bit systems, the isize becomes i32 and the usize becomes u32. Likewise, for 64-bit systems, the isize is i64 and u64 for usize. They are mostly used in indexing a collection, like arrays and vectors.

Each integer variant can only store integers of a certain range due to their size and sign. For signed integers i-n the range is between to inclusive. For unsigned integers u-n, its range is between to .

So i8 can store integers from to which is to . While u8 can store integers from to , which equals . You can do this for other integer variants to get their ranges.

8 bits equals 1 byte, so an i8 and u8 integer takes up 1 byte in the memory, 16-bit integers take up 2 bytes, 32 take up 4, 64 take up 8, and so on.

To get the size of a data type, you can use the size_of function obtained from the mem module of the standard library.

        use std::mem::size_of;

fn main() {
  println!("size of i8   : {}", size_of::<i8>());

  println!("size of i16  : {}", size_of::<i16>());

  println!("size of i32  : {}", size_of::<i32>());

  println!("size of isize: {}", size_of::<isize>());
}
      
        size of i8   : 1
size of i16  : 2
size of i32  : 4
size of isize: 8
      

This also works for compound types like structs and tuples, even pointers and references.

Representing integer literals in Rust

There are several ways of writing a number literal. For large numbers, we can separate the numerals with an underscore for better readability.

        //without underscore
let large_num = 12345678;

//with underscore
let large_num = 12_345_678;
      

Integers can be written in hexadecimal format, in octal, or in binary form. For hexadecimal format, we prefix the integer with 0x, 0o for octal, and 0b for binary.

        //decimal
let x = 42;

//hex
let y = 0x2a;

//octal
let z = 0o52;

//binary
let w = 0b101010;
      

When the values of the integers are printed they will be displayed in their decimal format.

        println!("x: {}", x);

println!("y: {}", y);

println!("z: {}", z);

println!("w: {}", w);
      
        x: 42
y: 42
z: 42
w: 42
      

To display an integer in hexadecimal format, in the curly brackets, we specify we want the integer to be formatted to hexadecimal by including a colon : followed by an x. For octal, we use o instead of x, and for binary we use b.

        println!("x: {}", x);

println!("y: {:x}", y);

println!("z: {:o}", z);

println!("w: {:b}", w);
      
        x: 42
y: 2a
z: 52
w: 101010
      

Integer Overflow in Rust

One more thing to talk about before moving to a floating point number is an integer overflow. Since each integer variant can only store integers of a specific range. What happens when it overflows?

Here, we have a u8 integer whose value is 255. Since 255 is the largest u8 integer, if we add 1, it overflows.

        // 255 is the max u8 integer
let x: u8 = 255;

// this will cause an overflow
let x: u8 = x + 1;
      

When we build this program, the program won’t compile since the compiler can detect the overflow.

If the variable comes from an external source, like the command line, the compiler can’t detect an overflow if we perform arithmetic operations on it.

Here, we have a program that reads two inputs from the command line and parses them to a u8 integer, then prints the sum of those 2 integers.

        fn main() {
  println!("Enter first number: ");
  
  let mut input = String::new();
  
  std::io::stdin().read_line(&mut input);

  let first_num: u8 = input.trim().parse().expect("Enter a valid u8 integer");

  println!("Enter second number: ");

  let mut input = String::new();

  std::io::stdin().read_line(&mut input);

  let second_num: u8 = input.trim().parse().expect("Enter a valid u8 integer");

  let sum = first_num + second_num;

  println!("sum: {sum}");
}
      

If we run this program in debug mode and enter two integers whose sum is greater than 255, the program panics.

But if we build and run this program in release mode, for the first integer enter 255, and for the second enter 1.

        Enter first number: 
255
Enter second number: 
1
sum: 0
      

The output is 0, this is because in release mode, instead of panicking, our program wraps it from the minimum. 256 becomes 0, 257 becomes 1, and so on.

        256   257   258   259   260
 :     :     :     :     : 
 0     1     2     3     4 
      

To handle the cases of overflow explicitly, you can use the wrapping add method, which wraps it from the minimum, 255 plus 2 equals 257, and since it’s greater than 255 the maximum unsigned 8-bit integer, it is then wrapped, and x becomes 1.

        let x = 255_u8.wrapping_add(2);
  
println!("wrapping_add : {:}", x);
      
        wrapping_add : 1
      

The overflowing add method wraps the sum but returns a tuple where the first element is the wrapped sum and the second element is a boolean indicating whether the sum was wrapped. In this case, it is true since 257 was wrapped to become 1.

        let y = 255_u8.overflowing_add(2);

println!("overflowing_add : {:?}", y);
      
        overflowing_add : (1, true)
      

The checked add method returns the None value if it overflows, and the saturating method saturates the value at the maximum or minimum depending on where it overflowed.

        let z = 255_u8.checked_add(2);

let w = 255_u8.saturating_add(2);

println!("checked_add    : {:?}", z);

println!("saturating_add : {:?}", w);
      
        checked_add    : None
saturating_add : 255
      

There are similar methods for other arithmetic operations like subtraction, multiplication, division, exponential and so on.

Floats

Floating point numbers are numbers with fractional components or decimal points. In Rust, a float can be either 32 bits or 64 bits in size.

64-bit floats have higher range and precision than 32-bit floats.

Floating point types are specified with an f followed by the number of bits.

        // floats (32-bit or 64-bit)

// 32-bit
let x: f32 = 3.14;

let y: f32 = 2.00;

// 64-bit
let a: f64 = 3.14;

let b: f64 = 2.00;
      

We can specify the type of float after the variable name and a colon or at the end of the number literal.

        // floats (32-bit or 64-bit)

// 32-bit
let x = 3.14_f32;

let y = 2.00_f32;

// 64-bit
let a = 3.14_f64;

let b = 2.00_f64;
      

If we do not specify the type of float, it is given an f64 by default.

        let a = 3.14 // => f64
      

A 32-bit float is a bit faster and uses less memory than a 64-bit float but it comes with less range and precision.

Below are the minimum and maximum values a 32-bit and a 64-bit float can have.

        f32::MIN ->          -3.40282347E+38  
f32::MAX ->           3.40282347E+38  
  
f64::MIN -> -1.7976931348623157E+308  
f64::MAX ->  1.7976931348623157E+308
      

32-bit floats are best used in graphics & video processing where memory usage is a primary concern and their range and precision are significant for the task.

64-bit floats on the other hand are used in scientific calculations and statistical analysis where precision is very necessary.

Byte Number Literal

There is also a kind of number literal called byte, which stores ASCII character literal as an 8-bit unsigned integer.

        let a = b'A'; // => u8
      

If we print out the variable a, we get the numerical value of the ASCII character ‘A’.

        println!("b'A = {a}");
      
        b'A' = 65
      

Replacing the character literal with a string literal will result in an array of bytes, which is an array of u8 integers.

        let a = b"Apple";
      

Printing out this variable we can see it’s an array containing the numerical value of each ASCII character.

        println!("{a:?}");
      
        [65, 112, 112, 108, 101]
      

Thanks for reading.