Digging Into Rust's Syntax
Last time we kicked off our study of Rust with a quick overview comparing it with Haskell. In this article, we'll start getting familiar with some of the basic syntax of Rust. The initial code looks a bit more C-like. But we'll also see how functional principles like those in Haskell are influential!
For a more comprehensive guide to starting out with Rust, take a look at our Rust video tutorial!
Hello World
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:
- Various sizes of integers, signed and unsigned (
i32
,u8
, etc.) - Floating point types
f32
andf64
. - Booleans (
bool
) - 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. Next time, 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.