Numbers

Rust Numbers

In the last post, we discussed Strings. In this post, we are going to look at the differences between Ruby’s numbers and Rust’s various number types.

Numbers, either integers or floats, are pretty straight forward in Ruby. You simply declare the value and the interpreter will decide if it is an Integer or a Float. Example Ruby code below:

# integers
my_int = 4
puts my_int.class # => Integer

# floats
my_float = 4.2
puts my_float.class # => Float

If you are familiar with Ruby, you may take for granted how easy it is to declare a numeric data type and not really think about the differences between a whole integer and a float with a decimal. Not until you have to explicitly control precision maybe. Now let’s look at the Rust equivalent:

// integers
let my_int: i32 = 4;
println!("{}", my_int);

// floats
let my_float: f32 = 4.2;
println!("{}", my_float);

Woah! You may be asking what an i32 and f32 are. Rust is more explicit about numeric data declaration and it is wonderful. If you have written any heavy math in Ruby, you may have spent time figuring out where nulls were coming from, or why you were receiving Integers when you anticipated a Float. The Rust compiler will catch those differences and give clear and meaningful feedback. Let’s show an example:

// mismatch
let my_integer: i32 = 4.24;
println!("{}", my_integer);

If you try to run the above code with cargo run, you’ll receive the following feedback from the compiler:

--> src/main.rs:41:27
   |
41 |     let my_integer: i32 = 4.24;
   |                     ---   ^^^^ expected `i32`, found floating-point number
   |                     |
   |                     expected due to this

This is not unique to Rust, many typed languages can provide similar feedback, but in Ruby this would not be caught. Let’s embrace this further and dig into these types more.

Let’s discuss what an i32 is in more detail. It means a 32 bit integer. 32 bits means a range of numbers from -2,147,483,648 to 2,147,483,647. That’s a pretty big range.

There are related ranges. For example, an i8 is an 8 bit integer. It has a range of -128 to 127. Notice those ranges contain negative and positive numbers. If you perhaps know that you do not want to permit negative numbers, there is a u8 primitive type as well, stands for unsigned 8 bit number. It has a range of 0 to 255. Let’s see this action:

// small positive number
let my_pos_int: u8 = 255;
println!("{}", my_pos_int);

Easy enough, the output is 255. What happens if we try assigning a negative value:

error[E0600]: cannot apply unary operator `-` to type `u8`
  --> src/main.rs:45:26
   |
45 |     let my_pos_int: u8 = -255;
   |                          ^^^^ cannot apply unary operator `-`
   |
   = note: unsigned values cannot be negated

The compiler provides such great feedback. It immediately tells us that we have a variable assigned a u8 data type and we’ve tried assigning an invalid value. The compiler can really be useful. Let’s see what it says when we try to mutate our previous variable that is right at the maximum value of the u8 range.

// small positive number
let mut my_pos_int: u8 = 255;
println!("{}", my_pos_int);

// let's try going out of range
my_pos_int += 1;
println!("{}", my_pos_int);

This results in the process panicking, with the following error:

thread 'main' panicked at src/main.rs:49:5:
attempt to add with overflow

Notice that is not a compilation error. That is a runtime error. When we try to overflow the number to a value outside of the data types range, the process panics. We still have to be careful not to use invalid values at runtime.

  • NOTE: when a Rust process panics, it means the process exits and stops running.

So let’s get into some operators and see how basic arithmetic compares between Ruby and Rust.

let mut left: u8 = 1;
let right: u8 = 2;
let total: u8 = left + right;
println!("{}", total); // 3

let total: u8 = right - left;
println!("{}", total); // 1

left += 1; // increment
println!("{}", left);

left -= 1; // decrement
println!("{}", left);

let equality = left == right;
println!("{}", equality);

let float_addition: f32 = (left as f32 + 0.23 as f32) as f32;
println!("{}", float_addition);

let cast_addition: f32 = f32::from(left) + 0.23;
println!("{}", cast_addition);

Notice line 18 has some complexity. We’ll pull it out for better inspection:

let float_addition: f32 = (left as f32 + 0.23 as f32) as f32;

Is all of that really needed just to add an integer to a float? Well, yes, kinda. At least an understanding of what is required. Notice in the assignment (the left side) we declare the value as a type f32. So our intention is to have a 32-bit float value. On the right side, we have to cast the left value we declared prior as a f32 because its original value of u8 doesn’t have an add method that supports a float. So left as f32 casts the value to a float and then we add 0.23. It may look verbose, but if you’re doing rather long math routines, you’ll appreciate the compiler catching things for you.

That’s quite a bit different from Ruby but is a good illustration of the more explicitness we’ll find around numbers in Rust. We’ll stop there for now and go into Number data types and their operations in more detail in future chapters where we address common use cases.

Let’s move on to the next object in Rust, Enums.