Rust Syntax Basics

Welcome to our series on the Rust language! Rust is a very interesting language to compare to Haskell. It has some similar syntax. But it is not as similar as, say, Elm or Purescript. Rust can also look a great deal like C++. And its similarities with C++ are where a lot of its strongpoints are.

In this series we'll go through some of the basics of Rust. We'll look at things like syntax and building small projects. In this first part, we'll do a brief high level comparison between Haskell and Rus, and examine some basic syntax examples. If you're already familiar with the basics, you can head over to part 2 of this series, where we'll discuss memory management in Rust.

To get jump started on your Rust development, take a look at our Rust video tutorial!. It will walk you through the installation process, and show examples on all the topics in this series!

Why Rust?

Rust has a few key differences that make it better than Haskell for certain tasks and criteria. One of the big changes is that Rust gives more control over the allocation of memory in one's program.

Haskell is a garbage collected language. The programmer does not control when items get allocated or deallocated. Every so often, your Haskell program will stop completely. It will go through all the allocated objects, and deallocate ones which are no longer needed. This simplifies our task of programming, since we don't have to worry about memory. It helps enable language features like laziness. But it makes the performance of your program a lot less predictable.

I once proposed that Haskell's type safety makes it good for safety critical programs. There's still some substance to this idea. But the specific example I suggested was a self-driving car, a complex real-time system. But the performance unknowns of Haskell make it a poor choice for such real-time systems.

With more control over memory, a programmer can make more assertions over performance. One could assert that a program never uses too much memory. And they'll also have the confidence that it won't pause mid-calculation. Besides this principle, Rust is also made to be more performant in general. It strives to be like C/C++, perhaps the most performant of all mainstream languages.

Rust is also currently more popular with programmers. A larger community correlates to certain advantages, like a broader ecosystem of packages. Companies are more likely to use Rust than Haskell since it will be easier to recruit engineers. It's also a bit easier to bring engineers from non-functional backgrounds into Rust.

Similarities

That said, Rust still has a lot in common with Haskell! Both languages embrace strong type systems. They view the compiler as a key element in testing the correctness of our program. Both embrace useful syntactic features like sum types, typeclasses, polymorphism, and type inference. Both languages also use immutability to make it easier to write correct programs.

Hello World

With all that said, let's get started writing some code! As we should with any programming language, let's start with a quick "Hello World" program.

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

Immediately, we can see that this looks more like a C++ program than a Haskell program. We can call a print statement without any mention of the IO monad. We see braces used to delimit the function body, and a semicolon at the end of the statement. If we wanted, we could add more print statements.

fn main() {
  println!("Hello World!");
  println!("Goodbye!");
}

There's nothing in the type signature of this main function. But we'll explore more further down.

Primitive Types and Variables

Before we can start getting into type signatures though, we need to understand types more! In another nod to C++ (or Java), Rust distinguishes between primitive types and other more complicated types. We'll see that type names are a bit more abbreviated than in other languages. The basic primitives include:

  1. Various sizes of integers, signed and unsigned (i32, u8, etc.)
  2. Floating point types f32 and f64.
  3. Booleans (bool)
  4. Characters (char). Note these can represent unicode scalar values (i.e. beyond ASCII)

We mentioned last time how memory matters more in Rust. The main distinction between primitives and other types is that primitives have a fixed size. This means they are always stored on the stack. Other types with variable size must go into heap memory. We'll see next time what some of the implications of this are.

Like "do-syntax" in Haskell, we can declare variables using the let keyword. We can specify the type of a variable after the name. Note also that we can use string interpolation with println.

fn main() {
  let x: i32 = 5;
  let y: f64 = 5.5;
  println!("X is {}, Y is {}", x, y);
}

So far, very much like C++. But now let's consider a couple Haskell-like properties. While variables are statically typed, it is typically unnecessary to state the type of the variable. This is because Rust has type inference, like Haskell! This will become more clear as we start writing type signatures in the next section. Another big similarity is that variables are immutable by default. Consider this:

fn main() {
  let x: i32 = 5;
  x = 6;
}

This will throw an error! Once the x value gets assigned its value, we can't assign another! We can change this behavior though by specifying the mut (mutable) keyword. This works in a simple way with primitive types. But as we'll see next time, it's not so simple with others! The following code compiles fine!

fn main() {
  let mut x: i32 = 5;
  x = 6;
}

Functions and Type Signatures

When writing a function, we specify parameters much like we would in C++. We have type signatures and variable names within the parentheses. Specifying the types on your signatures is required. This allows type inference to do its magic on almost everything else. In this example, we no longer need any type signatures in main. It's clear from calling printNumbers what x and y are.

fn main() {
  let x = 5;
  let y = 7;
  printNumbers(x, y);
}

fn printNumbers(x: i32, y: i32) {
  println!("X is {}, Y is {}", x, y);
}

We can also specify a return type using the arrow operator ->. Our functions so far have no return value. This means the actual return type is (), like the unit in Haskell. We can include it if we want, but it's optional:

fn printNumbers(x: i32, y: i32) -> () {
  println!("X is {}, Y is {}", x, y);
}

We can also specify a real return type though. Note that there's no semicolon here! This is important!

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

This is because a value should get returned through an expression, not a statement. Let's understand this distinction.

Statements vs. Expressions

In Haskell most of our code is expressions. They inform our program what a function "is", rather than giving a set of steps to follow. But when we use monads, we often use something like statements in do syntax.

addExpression :: Int -> Int -> Int
addExpression x y = x + y

addWithStatements ::Int -> Int -> IO Int
addWithStatements x y = do
  putStrLn "Adding: "
  print x
  print y
  return $ x + y

Rust has both these concepts. But it's a little more common to mix in statements with your expressions in Rust. Statements do not return values. They end in semicolons. Assigning variables with let and printing are expressions.

Expressions return values. Function calls are expressions. Block statements enclosed in braces are expressions. Here's our first example of an if expression. Notice how we can still use statements within the blocks, and how we can assign the result of the function call:

fn main() {
  let x = 45;
  let y = branch(x);
}

fn branch(x: i32) -> i32 {
  if x > 40 {
    println!("Greater");
    x * 2
  } else {
    x * 3
  }
}

Unlike Haskell, it is possible to have an if expression without an else branch. But this wouldn't work in the above example, since we need a return value! As in Haskell, all branches need to have the same type. If the branches only have statements, that type can be ().

Note that an expression can become a statement by adding a semicolon! The following no longer compiles! Rust thinks the block has no return value, because it only has a statement! By removing the semicolon, the code will compile!

fn add(x: i32, y: i32) -> i32 {
  x + y; // << Need to remove the semicolon!
}

This behavior is very different from both C++ and Haskell, so it takes a little bit to get used to it!

Tuples, Arrays, and Slices

Like Haskell, Rust has simple compound types like tuples and arrays (vs. lists for Haskell). These arrays are more like static arrays in C++ though. This means they have a fixed size. One interesting effect of this is that arrays include their size in their type. Tuples meanwhile have similar type signatures to Haskell:

fn main() {
  let my_tuple: (u32, f64, bool) = (4, 3.14, true);
  let my_array: [i8; 3] = [1, 2, 3];
}

Arrays and tuples composed of primitive types are themselves primitive! This makes sense, because they have a fixed size.

Another concept relating to collections is the idea of a slice. This allows us to look at a contiguous portion of an array. Slices use the & operator though. We'll understand why more after the next article!

fn main() {
  let an_array = [1, 2, 3, 4, 5];
  let a_slice = &a[1..4]; // Gives [2, 3, 4]
}

What's Next

We've now got a foothold with the basics of Rust syntax. You should now move on to part 2, where we'll start digging deeper into more complicated types. We'll discuss types that get allocated on the heap. We'll also learn the important concept of ownership that goes along with that.