Basic Syntax of Rust

Pretty much every (procedural) programming language provides constructs to (1) define a variable, (2) define a function, and (3) control the flow of execution. If you understand those constructs for a given language, you will be able to start writing simple programs in that language. Rust is no exception, so let's take a look at how Rust provides those constructs at the very basic level first.

Variable Definitions

The following program shows how to define variables in Rust.

fn main() {
    let a: i32 = 1;
    let b: i32 = 2;

    println!("a + b is {}", a + b);
}

But before we go further, let's note a couple of things.

  • main() is a function and it is the default entry point for a Rust program, similar to C/C++. If you create a new cargo project using cargo new, it will create a main() function in src/main.rs.
  • Let's not worry about println!() for now. All you need to know at this point is that it prints out formatted strings.

In the above program, we can make a few observations regarding variable definitions.

  • There are two variable definitions in the program.
    • let a: i32 = 1 means that a is a 32-bit integer and 1 is its initial value.
    • let b: i32 = 2 means that b is a 32-bit integer and 2 is its initial value.
  • A variable definition starts with let, e.g., let a: i32 = 1.
  • Each variable has a type, e.g., i32 (a 32-bit integer) in let a: i32 = 1. When you declare the type for a variable, the syntax is the variable name, followed by :, followed by the type, e.g., a: i32.
  • A variable definition ends with a semicolon (;). In Rust, all statements end with a semicolon (;). Rust makes a distinction between a statement and an expression, which we will discuss later.

Although Rust requires each variable to have a type, you don't always need to declare it because oftentimes the Rust compiler can infer a variable's type. We can revise the above code as follows and the Rust compiler will automatically understand the types for a and b.

fn main() {
    let a = 1; // No explicit type declaraion here.
    let b = 2; // Not here either.

    println!("a + b is {}", a + b);
}

Keep in mind, however, that it's not always possible for the Rust compiler to infer a variable's type. If that's the case, you still need to explicitly declare the type.

The Rust book has a section on data types and you can take a look at the primitive data types available in Rust.

Function Definitions

Let's look at another piece of code that shows how to define a function.

fn main() {
    let a = 1;
    let b = 2;
    print_sum(a, b);
}

fn print_sum(x: i32, y: i32) {
    let sum = x + y;
    println!("Sum: {}", sum);
}

Here also, we can make a few observations.

  • There are two function definitions. One is for main() and the other is for print_sum(). Again, main() is the default entry point for Rust programs.
  • A function definition starts with fn.
  • print_sum() has two parameters, one is x and the other is y.
    • x: i32 means that the parameter name is x and the type is i32.
    • y: i32 means that the parameter name is y and the type is i32.

Unlike variables, parameters always need a type. Run the following code (that doesn't declare types for parameters) and see what error messages you get.

fn main() {
    let a = 1;
    let b = 2;
    print_sum(a, b);
}

fn print_sum(x, y) {
    let sum = x + y;
    println!("Sum: {}", sum);
}

Typically, you want to return something as a result of calling a function. The following code shows the syntax for it.

fn main() {
    let a = 1;
    let b = 2;
    let s = sum(a, b);

    println!("a + b is {}", s);
}

fn sum(x: i32, y: i32) -> i32 {
    x + y
}

There are a couple of differences that we can see.

  • sum() has a return type declared in its definition -> i32. It means that it returns a 32-bit integer (i32) as a return value.
  • x + y is the return value. Notice a couple of things there.
    • There is no return keyword.
    • There is no semicolon at the end.

Not having a semicolon x + y means that x + y is an expression, and as mentioned earlier, we will discuss it later.

You can use return in a function, either at the end or in the middle (for an early return). The following code works exactly the same way except that it uses a return statement (notice ; at the end) instead.

fn main() {
    let a = 1;
    let b = 2;
    let s = sum(a, b);

    println!("a + b is {}", s);
}

fn sum(x: i32, y: i32) -> i32 {
    return x + y;
}

Read the Rust book on functions to understand the details about functions.

Execution Control

To control the flow of execution, Rust provides a few constructs that are similar to the ones provided by other languages. Although they are similar, Rust's constructs for execution control are often more powerful. There are typically two types of control you'd like---branching and looping. Rust provides if-else if-else and match for branching and loop, while, and for for looping.

Branching with if

Let's look at the following code, which shows how if-else works in Rust.

fn main() {
    let a: i32 = 1;

    if a == 1 {
        println!("a is 1");
    } else if a == 2 {
        println!("a is 2");
    } else {
        println!("a is something else");
    }
}

It looks very similar to the if-else constructs in other popular languages such as C/C++ or Java, except that there are no parentheses for a branch condition. However, there is a notable difference---if-else evaluates to a value. To understand what this means, let's look at the following code.

fn main() {
    let a = 1;

    let b = if a == 1 {
        2
    } else if a == 2 {
        1
    } else {
        0
    };

    println!("b is {}", b);
}

As you can see, b gets its initial value assigned from the result of if-else. This is because if is an expression, not a statement. An expression in Rust evaluates to a value while a statement doesn't. In Rust, most of the constructs are expressions, e.g., if, for, a code block {}, etc. If you add a semicolon at the end of an expression, it becomes a statement (called an expression statement) and the value that the expression evaluates to gets ignored.

Exactly what value an if evaluates to needs some more explanation. The first thing to understand is that a block is also an expression and it evaluates to the last expression of the block. Run the following code and see what the result looks like.

fn main() {
    let a: i32 = {
        println!("In the block");
        1 // This is an expression. There's no `;`.
    };
    println!("a is {}", a);
}

Since a block evaluates to its last expression, a gets 1. In order to see the difference between an expression and a statement, run the following code and see what error messages you get.

fn main() {
    let a: i32 = {
        println!("In the block");
        1; // This is a statement, not an expression due to `;`.
    };
    println!("a is {}", a);
}

If a block does not have the last expression, it gets () (sometimes referred to as the unit type) as its type. () has a single value, which is also (), and it is used "when there's no other meaningful value that could be returned".

Now if evaluates to the value of the block that corresponds to the correct condition. In the following code (which is the same code from above), if evaluates to 2 because a == 1 and the corresponding block evaluates to 2.

fn main() {
    let a = 1;

    let b = if a == 1 {
        2
    } else if a == 2 {
        1
    } else {
        0
    };

    println!("b is {}", b);
}

Branching with match

Rust provides another branching construct called match and you will probably find yourself using it very often due to its power. However, understanding its power requires the understanding of Rust's pattern matching ability, so we will not delve into that for now. The following code is a revised version of the very first code we saw for if and it uses match instead of if-else if-else.

fn main() {
    let a: i32 = 1;

    match a {
        1 => { // If `a` matches `1`, execute this.
            println!("a is 1");
        }
        2 => { // If `a` matches `2`, execute this.
            println!("a is 2");
        }
        _ => { // `_` works as a wild card and it matches any expression
            println!("a is something else");
        }
    }
}

With match, you provide an expression to match (e.g., a) and list out execution options (e.g., 1 => {}, 2 => {}, etc.). Each option is called a match arm. Unlike if, a match expression does not have to be a boolean expression as you can see from the code above.

As mentioned earlier, match is much more powerful than if due to pattern matching, but we will cover that later.

Loops

Rust provides three types of loops---loop, while, and for.

loop

loop is probably the most interesting loop construct, especially in conjunction with break. loop is an infinite loop construct and if you want to break out of the loop, you need to use break.

fn main() {
    let mut a: usize = 0; // Don't worry about `mut` for now.

    loop {
        println!("This is infinite...");
        a += 1;
        if a == 10 {
            println!("Unless there's a break");
            break;
        }
    }
}

An interesting thing about loop and break is that loop is an expression and it evaluates to what break returns.

fn main() {
    let mut a: usize = 0; // Don't worry about `mut` for now.

    let b = loop {
        println!("This is infinite...");
        a += 1;
        if a == 10 {
            println!("Unless there's a break");
            break a;
        }
    };

    println!("b is {}", b);
}

In the code, break returns a and loop evaluates to it. Thus, a's value is assigned to b.

while

while is almost exactly what you would expect.

fn main() {
    let mut a: usize = 0; // Don't worry about `mut` for now.

    while a < 5 {
        println!("a is {}", a);
        a += 1;
    }
}

As with other languages, while provides a conditional loop.

for

You can use for instead of while, but you will typically use for to iterate over a collection such as an array.

fn main() {
    let a = [0, 1, 2, 3, 4];

    for i in a {
        println!("a contains {}", i);
    }
}

In order to use for to iterate over a collection such as an array, you need to get an iterator for the collection. In the code above, even though it looks like you are using a directly, that's actually not the case. The Rust compiler understands that you are iterating over a and it replaces it with a proper iterator.

You can also use range operators, .. or ..=, with for. The first one, .., is a right exclusive range operator while ..= is a right inclusive range operator.

fn main() {
    let a: usize = 1;

    for i in 0..a {
        println!("Right exclusive iteration: {}", i);
    }

    for i in 0..=a {
        println!("Right inclusive iteration: {}", i);
    }
}