Floats

The println! macro also works with numbers.

fn main() {
    let x = 1.1;
    let y = 2.2;

    println!("x times y is {}", x * y);
}

This prints "x times y is 2.4200000000000004" rather than the of 2.42 we'd get using normal arithmetic. That's because these are standard IEEE-754 binary floating-point numbers, which is what many languages use to represent decimal numbers. Various operations on these numbers can result in values of infinity, negative infinity, or "not a number" - when certain error conditions happen, like division by zero.

Mutability

Once a let value has been initialized, it cannot be reassigned to a different value. For that, you need let mut.

fn main() {
    let x = 1.1; // x can never be reassigned
    let mut y = 2.2;

    y = 3.3; // Works fine
    y += 4.4; // Syntax sugar for y = y + 4.4

    println!("x times y is {}", x * y);
}

If we added x = 2.2; to the end of this function, it would no longer compile. That's because x was not defined with let mut! In general, Rust tends to prefer giving errors at compile time rather than at runtime.

Numeric types

Rust also gives type errors at compile time. Rust is a statically type-checked language, which means every value has a single type associated with it at compile time, and that type can never change at runtime.

For example, if we define let mut y and first assign it to a number, then later a string, Rust will give us a compile-time error:

let mut y = 2.2;

y = 3.3; // no problem
y = "three point three"; // compile-time error; y changed types!

We can use type annotations to specify exactly which types our values have. For example, we could add type annotations to our x and y declarations:

fn main() {
    let x: f64 = 1.1;
    let y: f64 = 2.2;

    println!("x times y is {}", x * y);
}

This says that x and y both have the type f64, which is short for "64-bit floating-point number." 64-bit floating point numbers are also what JavaScript uses for its normal number type.

With let this type annotation is optional by default, because Rust has a type inference system that infers types for values automatically. If it's unable to infer the type, it will give a compile-time error and ask you to add an annotation to specify exactly the type you want, but it will never silently infer the wrong type.

Functions

When we declare a function, the types of its arguments and return value are not inferred; we have to write the type annotations explicitly:

fn main() {
    println!("1.1 times 2.2 is {}", multiply_both(1.1, 2.2));
}

fn multiply_both(x: f64, y: f64) -> f64 {
    return x * y;
}

This says that multiply_both takes two f64 arguments and returns a f64. The reason we don't have any type annotations on our main function is that it has no arguments and no return value.

Notice that println! ends in a ! but multiply_both does not. The ! indicates that println! is a macro, not a function. Rust macros always end in ! whereas normal function calls never do. Macros can do some things that functions can't (such as the {} formatting trick), but they also have some downsides compared to functions. You can define your own macros, but since doing that is generally considered an advanced technique, we won't dive any further into macros in this introductory workshop.

Float sizes

Rust has two different sizes of float: f64 and f32. The former is 64 bits (8 bytes) large, whereas the latter is 32 bits (4 bytes) large.

The trade-off is that f64 can store more digits, making it more precise and able to represent larger numbers, but it takes up more memory. This memory difference can really add up in applications that store massive quantities of floating-point numbers. For example, in 3D games, f32 is very commonly used instead of f64.

Converting between numeric types with as

f32 and f64 are different types, so if you try to use them interchangeably, you'll get a type mismatch at compile time:

fn main() {
    let x: f64 = 1.1;
    let y: f32 = 2.2;
    let z = x * y;

    println!("x times y is {}", z); // ERROR: incompatible types!
}

You can use the as operator to convert one numeric type to another.

fn main() {
    let x: f64 = 1.1;
    let y: f32 = 2.2;

    println!("x times y is {}", x * y as f64);
}

Converting from f32 to f64 just takes up a bit more memory, but converting from f64 to f32 can result in information loss because f32 can't store as much information. This means converting from f64 to f32 and back to f64 can result in a different f64 value than the one you started out with!

You can only use as to convert between numeric types, not to (for example) convert between strings and numbers.