Previous | Table of Contents | Next |
In this section, we'll be going over the basic syntax of Rust. You'll learn about variable declarations and mutability, how to work with functions, and some basic control structures including loops, conditionals, and matching. Finally, we will analyze a simple program to find Collatz numbers that employs the topics in this section.
1 | let foo = 5; |
There are several things you should notice about this.
First, the keyword
let
.
let
is used to declare local variables in Rust, and must preface every such declaration.
Second, if you're accustomed to other statically-typed languages, you've probably noticed that there is no explicit statement of type here; instead, the compiler infers that
foo
should be an int.
Rust's compiler is typically very good at correctly inferring types for variables. However, there are times that it draws an incorrect conclusion, usually when the type a variable could be is in some manner ambiguous. We can circumvent this by explicitly telling the compiler what type you want the variable to be, as so:
1 | let foo: int = 5; |
Rust's primitive types are similar to those in other C-like languages. There are more details at the official tutorial; look for "Primitive types and literals".
Finally, you should notice that the above declaration uses =, as you would expect, for assignment, and terminates the statement with a semicolon. Operators in Rust are essentially all the same as in languages with which you're familiar, and we'll discuss the difference between statements and expressions (which are not terminated by a semicolon) later in this section. To summarize, declarations of local variables in Rust follow the form:
let name[: type] [= value];
1 2 | let foo = 5; foo = 6; |
The above code is invalid Rust; it will not compile. Instead, you'll get:
test.rs:3:2: 3:5 error: re-assignment of immutable variable `foo`
test.rs:3 foo = 6;
^~~
test.rs:2:6: 2:9 note: prior assignment occurs here
test.rs:2 let foo = 5;
^~~
error: aborting due to previous error
This is because all Rust local variables are,
by default, immutable - meaning that you cannot change their assigned value once given.
In other words, variables are not actually variable by default. This will be familiar if
you have experience with functional programming. In order
to make a mutable local variable, you must introduce another keyword -
mut
. The following code will work:
1 2 | let mut foo = 5; foo = 6; |
Rust features an additional type named a tuple. Tuples are groupings of values, similar in appearance but different in function to a list of variables.
Making a tuple is easy. The values to be included are enclosed in parentheses and separated by commas. One nice feature is that tuples are heterogeneous; we can mix field types.
1 | let tup = (4, 5.0, false, "hello"); |
There are a few caveats that we need to be wary of:
Rust’s conditionals are very similar to what you’ve probably seen in Java or another
C-family language, with two slight exceptions. The associated keywords are
if
,
else if
, and
else
, and are followed by a boolean expression (it must be of type
bool
; no type conversion will take place automatically) in the case of
if
and
else if
. The boolean expressions need not be in parentheses, but the body of the conditional
block must be enclosed in braces. That is, whereas in Java, you could have something like:
1 2 | if(foo == 5) System.out.println("it worked"); |
the equivalent in Rust of
1 2 | if foo == 5 println("it worked"); |
is illegal. The following is a legal conditional block. Note that the else and else if are optional for validity.
1 2 3 4 5 6 7 8 9 | if foo == 5 { println("it’s five"); } else if foo == 6 { println("it’s six"); } else { println("it’s not five or six"); } |
Rather than using a “switch” statement, as you may have seen in other
languages, Rust uses the
match
statement.
match
is like a significantly more powerful and useful switch, and is used fairly
extensively in a lot of Rust code. You'll run into it frequently, particularly
in the error handling section. Here's an example:
1 2 3 4 | match isOdd(x) { true => println("Odd"), // Notice the comma false => println("Even") } |
A
match
statement evaluates the first branch with a matching pattern. The pattern is an expression
with the same type as the object being matched. If the object has the same value as one that the pattern
accepts (see below for accepting multiple values with a single match arm), then the corresponding arm
will be evaluated.
If we want to include more than one statement in a branch of the
match
, we have to surround the code in braces. With braces, we don't need a comma,
but they're allowed.
1 2 3 4 | match isOdd(x) { true => { println("Odd"); 0 } false => { println("Even"); 1 } } |
The compiler checks that at least one pattern of the
match
expression will always match. When a variable is matched, the patterns must
completely represent the possible values the variable could hold. This is easy
for booleans; there's just
true
and
false
. For other types, listing every possible value would be tedious and awful.
To prevent insanity, Rust includes a _
that matches everything.
1 2 3 4 5 6 | let x = 4; match x { 0 => { ; } // Do nothing 4 => { foo(); } _ => { bar(); } // Matches every integer value } |
1 2 3 4 5 6 7 8 | int x = 4; switch (x) { case 0: break; case 4: foo(); break; default: bar(); break; } |
match
can also use simple logical expressions in its arms. For example,
1 2 3 4 5 | match x { 3|5|6 => { println("First arm!"); } 10..16 => { println("Second arm!"); } _ => { println("Default arm!"); } } |
will print "First arm!" if x is 3, 5, or 6, "Second arm!" if x is between 10 and 16, and "Default arm!" otherwise.
Pattern matching is used to access tuple values. If we don't care about a value,
we can use _
to ignore it.
1 2 | let tup = (4, 5.0, false, "hello"); let (a, b, c, _) = tup; |
The _
is handy for
match
statements too.
1 2 3 4 5 | match status { (0, true) => println("Success"), (_, true) => println("Pyrrhic victory"), // Any first value matches (_, _) => println("Complete loss") // Any pair of values will match } |
(note that in this case status
would need to be of type
(int, bool)
)
match
also allows for "pattern guards" - logical expressions that can be used to further
narrow down what a particular arm selects. For example:
1 2 3 4 5 | match x { (x,y) if x > y => { println("Decreasing"); } (x,y) if y > x => { println("Increasing"); } _ => { println("Equal")} } |
will print correctly the relation between x
and y
.
To quickly test your comprehension of what you've learned so far, try writing a simple program to do the following:
Given a tuple containing an int and a bool, use a match statement to determine (a) if the bool is true and the int is
between 20 and 26, (b) if the bool is true and the aforementioned condition isn't true for the int,
(c) if the int is between 40 and 49 (where the value of the bool doesn't matter), and (d), wherein none of the previous
conditions are true. Print out an appropriate message using println
for each case (e.g. for (a), you could print
"True and in range", or something of the sort). Since you haven't learned how to get input yet, just define a variable like this:
1 | let x = (51, true); |
match on it, and manually change its value to test the different branches.
Put your code inside a block of this form, and run "rustc
1 2 3 | fn main() { // Code goes here } |
Rust provides several choices of looping structure, similar to those in C and Java.
In Rust loops, we can use
break
to get out of the loop and
continue
to skip to the next iteration.
while
:while
loop iterates until its condition is false. Its condition must be of type bool.
1 2 3 4 5 | let mut i = 0; while i < 10 { println("Hi there"); i += 1; // Rust doesn't support ++ or -- } |
1 2 3 4 5 | int i = 0; while (i < 10) { System.out.println("Hi there"); i++; } |
Its syntax is
while condition { code }
loop
:loop
is syntactic sugar for
while true
.
For completeness, its syntax is
loop { code }
for
: range(start, end)
, which creates a set of integers, [start, end)
.
1 2 3 4 | // Calls foo with 0, 1, ..., 9 for i in range(0, 10) { foo(i); } |
1 2 3 4 | // Calls foo with 0, 1, ..., 9 for (int i = 0; i < 10; i++) { foo(i); } |
Rust’s use of the semicolon may seem confusing when first encountered, but, once learned is intuitive and remarkably useful. Essentially, everything that doesn’t end with a semicolon is an expression, and everything that does is a statement. Expressions have an associated value, whereas statements do not (technically, they have a value of nil or void, but for our purposes this is the same as having none.) You can think of the semicolon in Rust as suppressing the value of an expression, turning it into a statement.
Almost everything in Rust can be an expression - the only exceptions are declarations.
This allows Rust code to be very nicely concise. For example, an explicit return from a function is not necessary in most cases.
In general, this use of expressions makes the
return
keyword necessary only when you want to leave a function early. Another common use of Rust's
expressions is easy conditional assignment of variables, as a conditional block will have the
value of its last expression:
1 2 3 4 5 6 7 8 9 | let foo = if x == 5 { "five" } else if x == 6 { "six" } else { "neither" } |
1 2 3 4 5 6 7 8 9 10 | String foo; if (x == 5) { foo = "five"; } else if (x == 6) { foo = "six"; } else { foo = "neither"; } |
The same can be done with pattern matching, as, again, the block will have the value of its last expression:
1 2 3 4 | let x = match y { 0..9 => { "Less than 10" } _ => { "Greater than 10" } } |
Functions are created by using
fn
. Like loops, their bodies must be surrounded
by braces.
Here’s the syntax for a function that accepts no parameters and doesn't return
anything:
fn name() { code }
And here is an example:
1 2 3 | fn foo() { println("foo"); } |
1 2 3 | void foo() { System.out.println("foo"); } |
As expected, this function can be called with
foo()
Functions can be declared inside other functions. This means that the following is valid Rust:
1 2 3 4 | fn foo() { fn bar() { println("bar"); } bar(); } |
When parameters are given to a function, their types must be specified using
the same syntax that specifies a type during variable declaration,
name :type
.
1 2 3 4 5 6 | fn rprime_sum(x: int, y: int, m: int) { match (x+y)%m { 0 => println("Multiple"), _ => println("Relatively prime") } } |
1 2 3 4 5 6 | void rprime_sum(int x, int y, int m) { if ((x+y)%m == 0) System.out.println("Multiple"); else System.out.println("Relatively prime"); } |
A return value is specified using -> type
after the parameter
list. To actually return a value, we use Rust's expressions.
1 2 3 | fn square(x: int) -> int { x*x } |
1 2 3 | int square(int x) { return x*x; } |
As another check to make sure you're good to go so far, try to implement Fizz Buzz, as specified on that page. Remember that you'll need to put your code in a main function, as shown above.
The conjecture is currently unproven, although it has been shown to hold for numbers up to 5476377146882523136.
1 2 3 4 5 6 7 | fn collatz(N: int) -> int { if N == 1 { return 0; } match N % 2 { 0 => { 1 + collatz(N/2) } _ => { 1 + collatz(N*3+1) } } } |
We can use this code in the following program to find the number of Collatz steps for a user-input number:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | use std::os; fn main() { if os::args().len() < 2 { println("Error: Please provide a number as argument."); return; } let i = from_str::<int>(os::args()[1]).unwrap(); println!("{:d} has {:d} Collatz steps", i, collatz(i)); } fn collatz(N: int) -> int { if N == 1 { return 0; } match N % 2 { 0 => { 1 + collatz(N/2) } _ => { 1 + collatz(N*3+1) } } } |
First, let's look at the main function. There are a couple of things at the beginning which you haven't seen thus far in the tutorial, namely the contents of the first conditional block:
1 2 3 4 | if os::args().len() < 2 { println("Error: Please provide a number as argument."); return; } |
We aren't going to worry about the meaning of the os::
in the boolean expression of the conditional block just yet;
for now, just know that
os::args().len()
gets the number of command-line arguments passed in.
println
, as you've probably guessed based on your use of other languages, prints the string argument passed in.
The conditional block ends with a return statement, which, as we discussed earlier, is being used to prematurely exit the function.
There's only one other thing to notice in the main function (we're ignoring line 9 - just know that it gets the first command line argument, changes it from a string to an int, and assigns that to a variable), and that's the slight variation in line 10's
println
statement. Specifically, notice the !, and the arguments being passed in. The ! specifies that the call is to a macro, which
is a kind of language extension in Rust. We'll be going over them in more detail later. In particular, this macro is like printf
in C, and takes as its first argument a format string, with the remaining arguments being used to fill the slots of the format string. The syntax Rust uses for its format strings is similar to that of
Python.
Moving into the collatz
routine, we see the function declaration syntax we discussed above.
collatz
takes in an int and returns an int:
1 2 3 4 5 6 7 | fn collatz(N: int) -> int { if N == 1 { return 0; } match N % 2 { 0 => { 1 + collatz(N/2) } _ => { 1 + collatz(N*3+1) } } } |
The main takeaway from this code is the use of a match block to carry out the actual Collatz conjecture algorithm.
Notice that we have to have the
_
arm of the block in order for it to be comprehensive; given this, it makes sense to only have
one other arm (as opposed to two other arms, one for 0 and one for 1).
Finally, notice again that, by not putting semicolons at the end of the calls in each arm of the match
block, we are able to use the value of whichever arm of the block is selected as our return value without
an explicit return statement.
To finish off this section, we have a small programming problem for you to solve. Starting with the above code (also available in a file
here
), make a program that takes as command-line input a single number, representing a number of Collatz steps
(steps required to reach 1 by following the Collatz procedure), and computes the lowest number (starting from 1) which requires this
number of Collatz steps. For example, if the number input was 949, your program should output 63,728,127; similarly, if you input 1132, it should output 9,780,657,630 as the lowest number requiring 1132 Collatz steps.
Since these are fairly large numbers, and it might take your code a very long time to reach them
(unless you use a more advanced technique, such as in some manner memoizing previous results and
efficiently checking to see if you've already found the number of steps remaining from a given number - but I digress)
you can use the following smaller test cases: For an input of 6, your code should output 10. For an input of 45, it should
output 361. Finally, for an input of 260, it should print 18514.
Bonus points if you can do it using each type of loop in Rust, as well as if you can do it recursively.
N.B.: Trying to call
collatz(0)
will result in a stack overflow, as the Collatz sequence is only defined for positive integers.
Ready for more Rust? Head to the next section.
Previous | Table of Contents | Next |