Rust for C++ developers part 1: Basic syntax

This is the first post from my Rust for C++ developers post series and it will focus mainly on the basic syntax. This includes variables, functions, conditional operators and some common Rust patterns around them. I'll try to walk you through some syntactic and conceptual similarities and differences between the two languages based on my experience. To follow this post you don't need to setup a compiler or an editor. You can write code online at the Rust Playground and it should be enough to run the examples. For each topic discussed in the post I'll provide links to the Rust book where you can find more detailed information. Feel free to skip them if you are in a hurry but if you have got the time it's worth to at least skim through the sections.

Before diving in the actual syntax let's talk about a few important differences between C++ and Rust on a conceptual level:

  • In Rust everything is immutable by default (like using const in C++). If you want a variable to be mutable you explicitly state this. If someone refers to a const in Rust, he/she usually means a constant value known at compile time. E.g. ANSWER=42.
  • Assignments in Rust perform move instead of copy. Moving is also the default way to pass a variable to a function. If you want to make a copy you need to explicitly state this by calling clone(). Only the primitive types (int, uint, etc) and types implementing Copy (more on this later) are copied by default.
  • Rust has got references but working with them is a bit more involved compared to C++. The compiler has got a borrow checker which makes sure you are not doing something bad like modifying a variable via a reference from more than one place.
  • The Rust compiler works very hard to catch problems in your code during compilation. This can be very intimidating in the beginning especially if you are used to the freedom of C or C++. But don't worry - you'll get used to this and even start liking it at some point. Of course don't think about the compiler as an advanced AI solving any logical errors of yours. The experience is more like 'clang-tidy on steroids'. Most of the time the compiler gives you a clear error message and even a suggestion about fixing your code.

Now let's see some Rust code.

Hello World

Each respectable programming language tutorial starts with a hello world app :). And this one won't be an exception:

fn main() {
    println!("Hello world");
}

The code is almost self-explanatory. fn is a keyword to define a (void in this case) function. println! is macro call, which is important. I won't go into macros just now but keep in mind there is a difference between my_func() and my_func!(). The former calls a function named my_func while the latter - a macro named my_func. ; are very important in Rust but we'll talk about them later in this post.

Defining variables

Variables are defined with let. Think about it as an alternative of auto in C++. let picks the right type for you in a similar fashion. Also remember that by default all variables are immutable in Rust unless you explicitly make them mutable with mut. Here is an example:

fn main() {
    let a = 3;
    let mut b = 4;

    println!("a: {}, b: {}", a, b);

    // a = 5; won't compile, a is not mut
    let a = 4; // but we can 'redefine it'. This is called shadowing
    b = 5;  // b can be changed because it is mut

    println!("a: {}, b: {}", a, b);

    b = b + 1; // there is no b++

    println!("a: {}, b: {}", a, b);
}

a is immutable and can't be changed. b is mutable and can be changed. You can redefine a variable with let and set it a new value for it. This is called shadowing and it is used very often in Rust codebases. There is no a++ or ++a. You can use a = a + 1 to increment values.

Variable declaration is explained in Section 3.2.

Defining functions and returning values

Let's see some more code:

fn sum(a: u32, b: u32) -> u32 {
    a + b
}

fn main() {
    println!("sum: {}", sum(2,2));
}

First let's talk about functions and their parameters. Here we already know that fn sum declares a function. a and b are its parameters. The format is parameter name: parameter type. The type after -> is the return type of the function. As a side note similar syntax is used if you want to set an explicit type for a variable with let. E.g. let a: u16 = 4; will make a u16 instead of u32. In practice you do this only if the compiler can't deduce the type of the variable for some reason. It's a bad practice to set explicit types.

More details about functions can be found here and here. You can find a complete list of the data types in Rust here.

In nutshell u32 stands for uint32_t, i32 for int32_t and so on. bool is bool. f32 is a 32bit float.

Statements and expressions

Statements and expressions are an important concept in Rust. My naive explanation is:

  1. A line ending with ; is a statement. A line without - an expression.
  2. The result of an expression is some kind of a value while the statement has got no result;

In the code sample above a + b is an expression and its result is the sum of the two variables. You can capture this value in a let statement (let c = a + b;).

Let's see an example:

fn main() {
    let a = 3;
    let mut b = 4;

    // Doesn't compile.
    // let d = let c = a + b;

    // Neither this:
    // let d = (let c = a + b);
}

Statements like a = b = c; are not valid in Rust.

Each scope in Rut can return an expression which can be captured in a variable. a in the following example is initialised in such way:

fn main() {
    let a = {
      let a = 1;
      let b = 1;
      let c = 1;

      let mut d = a + b + c;
      d = d + 1;

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

Note the opening brace after let a =. It creates a scope which ends with the statement d + 1. You can do whatever you want in this scope - arithmetic calculations, call functions, read data from a socket, etc. The expression at the end of the scope is returned as a result and a is initialised with it. Note that the expression should be the last line of the scope. Putting any statements after it will result in a compilation error.

The statements are discussed here in the Rust book.

Back to the function return values

You can return a value from a function in two ways:

  1. With an expression at the end of the function.
  2. Explicitly with a return, anywhere from the function.

You can also use return at the end of the function (as in C++) but this considered unidiomatic. For example this is how a function summing two u32s should look like:

fn sum(a: u32, b: u32) -> u32 {
    a + b
}

If the type of the expression doesn't match the return type of the function (e.g. a and b are u16 in the example above) you will get a compilation error.

Here is another example with a function which does an 'early return':

fn sum(a: u32, b: u32) -> u32 {
    if a == 42 || b == 42 {
        return 42
    }

    a + b
}

Replacing return 42 with just 42 doesn't make sense because the compiler will expect something before the if to capture the value. To avoid ambiguity an explicit return is used here.

You might wonder what could capture the statement 'before the if' in the previous example? Here is an example:

fn sum(a: u32, b: u32) -> u32 {
    let result = if a == 42 || b == 42 {
        42
    } else {
        a + b
    };

    result
}

result here is initialized with an if statement. Note that both if and else bodies return an expression of the same type (u32). Also note the ; after the else block. Its purpose is to terminate the let result = ... statement. Skipping it will yield a compilation error because the whole let result = if .... else ... statement will be interpreted as an expression.

This let if initialisation is explained in the Rust book here.

Are these initialisations actually used in practice?

Yes! They are used quite a lot in Rust so get used to them. Sometimes they are even abused. I've seen an if statement of almost 100 lines used for initialisation. I don't think this is a good practice but sometimes it might be the less evil.

Control flow in Rust

You already saw how an if statement looks in Rust - similar to those in C++ but without the braces. You also saw the 'if let' construct. We haven't talked about loops which luckily are quite similar to those in C++.

In Rust while is called 'conditional loop'. There is a special keyword for infinite loop - loop. Additional feature of loop is that it can return a value. I don't think this feature is used often but it's a cool thing to know.

Let's see an example:

fn main() {
    let mut a = 5;

    while a > 0 {
        a -= 1;
    }

    let flag = loop {
        break 5;
    };

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

Loops in Rust also has got labels which are useful to break out of inner loops but I think it's not worth discussing them now. They are discussed here in the Rust book.

I'm omitting the for loop on purpose. It's mainly used to iterate over containers which I'll keep for a later post.

Chapter 3 from the Rust book is dedicated to control flow. Feel free to check it out if you are interested in any of the subjects I have skipped.

Conclusion

This post covered the very basics of Rust's syntax but now you should have some idea how the language feels like. Don't miss part 2 which covers more interesting topics like references, structs and traits.

Comments

Comments powered by Disqus