Syntax and Basic Concepts
Learning objectives
- You know how to define and use variables in Rust.
- You know how to define and use functions in Rust.
- You know how Rust handles data types and numerical computation.
Main function and program output
Similar to many other languages, especially those in the C-family, such as C, C++, Java, and countless others, Rust applications have an obligatory main function, which serves as a starting point for the code. Thus, the simplest (yet useless) Rust application comprises merely an empty main function:
As is custom in the programming world, we will start our learning with printing text as our program output. This allows us to make useful — or at least seemingly useful — programs with minimal knowledge about the language.
Printing in Rust can be done via the macros print!
and println!
— Rust macros will be explained further in the course, for now we will just use them as we would use ordinary functions. As an example, the following two code snippets will both print out the line This is going to be printed on the same line
.
The difference between the two macros is that print!
prints text and println!
prints text with an added newline character \n
at the end.
String literals, like "this is a string literal"
, may span over multiple lines.
Escaping and raw strings
Escaping a character provides the character an alternate meaning. Escaping is done via adding a backslash \
in front of the character we want to escape. For example escaping the character n
represents the newline character \n
.
Since the backslash is a special character used for escaping, in order to print a backslash character we need to escape it with another backslash: \\
.
Or, we may opt to use Rust's raw string literal syntax that does not escape characters and treats the backslash like any other character. Raw string literals can be created by surrounding the string literal in hash signs #
and prefixing that with r
.
The hash signs are optional, but they allow for double quotes "
to be used in the string.
Comments
Comments allow writing natural language explanations in code to make it easier to read and understand. Comments are not executed by the compiler, so they can be used to write notes to yourself or other programmers without affecting the program's behavior.
In Rust, single line comments start with a double slash //
, and multiline aka block comments can be inserted into code with /*
(start block comment) and */
(end block comment).
We will be using comments in code throughout the course examples and exercise code templates. We also encourage you to insert comments in your code when you feel like it could help make sense of your code. After all, not all code can be made easy to understand, even when using good variable and function names. Comments can help you and others understand your code better.
Rust provides a built-in mechanism for writing semantic documentation comments. These come in different flavors but the most common is the triple-slash ///
doc-comment.
Documentation comments can be used to write for example markdown-formatted text in the source code to document a function's usage. The documentation comment generated text is what we see when hovering over a function in most IDEs (e.g. VS Code). You can try this out by hovering your mouse over a function call for a function that you've written doc-comments for.
One more thing about documentation comments! The markdown documentation can be converted to a website using the rustdoc
tool or cargo doc
. Try to run cargo doc --open
in the terminal in your Rust project directory to see the generated documentation pages for your project, for instance with the above example code in main.rs
.
More information and examples of Rust comments can be found in Rust documentation.
Variables
Variables in Rust are defined using the let
keyword.
The values of variables can be printed using the now familiar print macros. We can use curly braces {}
to insert a variable into the format string of the println!
macro invocation like this: x = {x}
.
We may also use positional or named parameters to indicate which variable goes where.
Variables can not be printed by giving the variable directly to the println!
macro. This is because the println!
macro expects a string literal as its first argument.
The following code will not compile. Run the code to see the error message and a compiler hint, which you can follow to fix the code.
To show or not to show
When we have an example which doesn't compile or has a bug, we can look for the button among the editor buttons to see a sample solution. The button resets the code back to the original.
Mutability
Variables in Rust are immutable unless explicitly set mutable. Once defined, the value of an immutable variable cannot change.
To define a variable mutable we can add a mut
keyword right after the let
keyword.
Let's see what happens if we try reassigning to an immutable variable. Execute the following code block.
The compiler notices that we tried to change the value of our immutable variable and kindly instructs us to make the variable mutable in case that's what we meant to do.
An intelligent code editor would spot this mistake without needing to even ask the Rust compiler unlike the simple embedded editor on this site. This is a gentle reminder to test out and write Rust also on your own computer in an editor with Rust support like VS Code.
Variables being immutable by default and requiring them to be explicitly set mutable to modify them comes from one the goals of Rust: safety. The language should guide the programmer naturally towards reliable code.
Immutable variables help avoid unintended modifications to the value of a variable. Consider defining a variable with the intent that its value should remain fixed. Then, perhaps some time in the future, the value of that variable is inadvertently modified somewhere in the code causing an unwanted change in some other part of the program— we'd have a bug that may be difficult to track.
Constants
Another way to bind a value to a name in Rust is constants, which are similar to immutable variables. The value of constant is fixed, it cannot change over the execution of the program.
A notable difference is that constants can be declared outside of functions making them global constants — let
can only be used to instantiate variables inside functions.
Declaring a constant is done with the const
keyword in a similar fashion as with let
, there are a few differences though as we'll see if we try to compile the following code. Then fix the code according to the instructions of the compiler (change the constant declaration to const RUST: &str = "is awesome";
).
In many languages, constants are encouraged to be written in uppercase. This is also the case in Rust, and by default, the compiler will warn about non-uppercase constants. But more importantly, the data type of a constant must be explicitly annotated like in the fixed example. Like the compiler is able to tell us, the &str
in the fixed example is the type of a string literal (value defined in double quotes ""
). We will go into data types in just a second once we are done with constants.
Constants can be any expression as long as that expression can be evaluated at compile-time. We call such expressions constant expressions.
Constants are useful for example to give meaningful names to hard-coded values, or to easily update a fixed hard-coded value used in multiple places in case such a value needs to be updated in the future.
Rust also has many pre-defined constants that we can readily use. For instance, we can use the ::
syntax to access the MAX
constant from the i32
module — MAX
being the maximum signed 32-bit integer.
Immutable variables, like constants, cannot have their values changed once defined. So what's the big difference?
The key difference between the two is that immutable variables have different values in different contexts. The same immutable function parameter can contain different values when the passed values are different. The same immutable variable may have different values, such as input from an external file. Constants are always the same value in all contexts.
The important technical differences are
- Constants can only be set to constant expressions, i.e. values that can be evaluated at compile-time. Immutable variables can be set to any value, including values known only at runtime.
- Constants must be annotated with their data type. We can let the compiler infer the type of an immutable variable.
- Constants can be declared anywhere, including outside functions aka globally. Immutable variables can only be declared in functions.
There is one more type of variable in Rust, static variables, which are similar to constants. When we try to define an immutable variable in globally, the compiler will suggest using not only const
but also static
.
Static variables can be used globally like global constants, but they are rarely used because constants are most often the better choice. We don't need to worry about them for now. We will come back to them later in the course when discussing memory in more detail.
Data types
Every variable and value in Rust has a data type, such as the 32-bit integer i32
or String
, the growable sequence of characters. Further, Rust is statically typed, which means that the type of every variable must be known at compile-time.
Knowing each type during compile-time enables enforcing stricter safety rules and provides IDEs, such as VS Code, the ability to provide useful information and auto-suggestions based on the data types we are working with. We don't need to write the types out constantly as we've already seen in multiple examples, all thanks to type inference.
The type of a value determines which operations can be performed on the value, and many data types have multiple useful out-of-the-box methods. For example the string literal &str
has to_lowercase
and replace
, and f32
has round
, abs
and max
that can make some programming tasks become a breeze.
We will not go through an exhaustive list of Rust's types, but look at a short overview and introduce new types when needed. For those interested, an exhaustive list of Rust's types can be viewed in the types section of Rust's documentation.
Explicit data types
Until now, we have let the compiler sort out what the data types of our variables are through type inference. This works just fine most of the time, but in some cases variable types have to be explicitly typed. For instance, a floating point literal such as 1.3
on its own can refer to a floating point value of any bytesize (supported in Rust, which are f32
and f64
). That being the case, the method round
cannot be used on arbitrary floating point values and requires explicit typing for the given floating point value.
We can explicitly define the variable type by typing a colon (:
) and the type after the variable name when declaring a variable.
In the case of scalar numbers, we can also define the type on the ambiguous value itself.
Primitive and custom types
Rust has multiple kinds of data types. Primitives, such as the 64-bit floating point f64
or 32-bit integer i32
are built into the language and structs and enums are custom data types built on top of the primitive types. The most important primitive types can be examined in the rust-by-example tutorial, which includes for instance the different integer and floating point types in Rust.
Floats and integers are also called scalar, i.e. single value, types because they are single values and cannot be directly separated into multiple values. Besides scalars, Rust primitives also include compound types, such as tuples and arrays that are usually composed of multiple values.
As a short list of primitive types, Rust has the following.
- scalars (integers, floats, booleans and characters)
- compound types (tuples, arrays and slices)
Scalar types usually have a lowercase name, e.g. char
, but structs and enums have a capitalized name by convention, e.g. the String
struct (String
is a growable and modifiable string unlike the string literal, which is a fixed value and cannot change).
Rust integer types come in two flavors: signed and unsigned. The signed types can store both positive and negative numbers, while the unsigned types can only store positive numbers. They also come in various sizes, from 8-bit to 128-bit. This way, the programmer can choose the size that best fits the application. There is no need to spend 128 bits of memory for a variable that will ever hold values fitting into 8 bits of memory (e.g. values between 0 and 28-1=255).
The following table lists the different integer types in Rust and their sizes.
Length | Signed | Unsigned |
---|---|---|
8 bit | i8 | u8 |
16 bit | i16 | u16 |
32 bit | i32 | u32 |
64 bit | i64 | u64 |
128 bit | i128 | u128 |
architecture dependent | isize | usize |
The arch types isize
and usize
depend on the kind of computer your program is running on: 64 bits on a 64-bit architecture and 32 bits on a 32-bit architecture.
We won't need to worry about too much which type to use when defining integer variables though. We can use the default type i32
for most cases and optimize or increase the variable size if necessary. For instance, it's good to use one of the unsigned variants when we know that the value should never be negative. As always when developing programs, we should remember the advice by computer scientist Donald Knuth: "premature optimization is the root of all evil".
Floating point types come only in two sizes, 32-bit for a smaller memory footprint and 64-bit for better precision. There are no unsigned floats.
Length | Type |
---|---|
32 bit | f32 |
64 bit | f64 |
The default type for floats in Rust is f64
. This is because using them on modern CPUs is roughly as fast as using f32
and memory is cheap nowadays.
Question not found or loading of the question is still in progress.
Functions
A function can be thought of as a named block of code that can be executed when needed in multiple places.
We have already defined main
functions and seen some built-in functions such as round
for floating point values and to_lowercase
for strings. Now we will look a bit closer into functions in Rust.
Defining and calling functions
Defining a function is done like with the main
function that we're already familiar with. We first use the fn
keyword to tell the compiler we are now defining a function, then the function name followed by parentheses()
, and finally we add the body of the function within curly braces {}
.
The custom in Rust is to write function names in snake_case
instead of for example camelCase
as in some languages.
We call a function by writing the function name followed by parentheses ()
. This causes the execution of the program to move into the called function.
Function parameters
Functions can have variables that capture the input values passed in the function call. These variables are called function parameters. Parameters are listed in the parentheses of the function declaration and all parameter types must be defined explicitly.
Note that the function parameter name command
does not need to match the variable name cmd
, because it is the value which is being passed to the function, not the variable.
A key difference between constants and immutable variables becomes evident with functions. Function parameters are immutable by default but they are not constants, because the variable has a different value depending on the value passed to the function. A program with only constants would make it difficult to write interactive code.
Returning from functions
Returning a value from a function can be done using the return
keyword, or by just leaving out the semicolon from the last expression. The latter method works because in Rust, the return value from a function is the value the function call expression evaluates to (unless we use an explicit return
).
The type of the return value — the return type — must be written in the function declaration using an arrow ->
, unless the function returns the unit value ()
which is used when there is no other meaningful value that could be returned.
The absence of data is denoted by the unit type ()
which is the default return type from functions. The only element of the unit type is also called unit (): ()
. If an expression ends in a semicolon, it evaluates to a unit.
Below, we use a return statement in a function for converting miles into kilometers. The show solution button shows the more concise expression-oriented way to return a value by omitting the semicolon from the conversion expression.
Note that the main
function doesn't need a specified return type because it returns a value of the unit type ()
.
Numerical computation
Basic calculation in Rust is done using familiar symbols and rules from math. 1 + 2
becomes 3
, 3 * 3
becomes 9
and parentheses define calculation order.
Some pitfalls in doing calculations should be kept in mind — these can be new for those coming from e.g. JavaScript and Python backgrounds where numbers are automatically typecasted.
In Rust, dividing an integer results in an integer with the quotient truncated.
And dividing an integer by zero causes panicking — in Rust lingo, program crashing during runtime due to an unrecoverable error is called panicking.
In the above example, we used an empty string's length to trick the compiler (in order to prevent it from inferring that the value of zero
is 0). Otherwise the compiler could spot the divide-by-zero error already during compile-time. Try to change zero
to be the integer literal 0
to see this in action.
Dividing floating point numbers is safer, since a float can be infinite or NaN (not a number).
We can perform arithmetic operations only on values of the same type. Trying to add an integer to a float will cause a compile-time error.
Likewise, different types of integers cannot be added together.
When we want to compute values of different types together, we need to explicitly convert the values to be the same type.
Casting values as
other types
Type conversions in Rust are mostly explicit. There are many built-in type coercions that every Rust programmer stumbles over at some point, but we have no need to worry about them yet.
Using the as
keyword, we can cast numerical values into other primitive types.
Similarly as with calculations, we need to be careful when casting values. Casting a float to an integer truncates the value and casting 128
to an i8
results in -128
since 127
is the largest value of i8
— casting to an integer wraps the value around if it is too large.
Some casts are not possible and will cause a compiler error. We need to be extra careful when casting to char
. Only u8 -> char
is allowed in Rust.
The as
casts only work for primitives and the behavior cannot be overridden for custom types. Custom types need to implement methods to convert between types, like &str
's to_string
to convert it to a String
.
An exhaustive list of possible casts can be found in the Rust Reference.
Summary of symbols
Symbol | Description |
---|---|
fn | Defines a function. Functions can have parameters and they may return back a value. If the return value is unspecified, the function returns by default a unit value () . |
r#""# | A raw string literal. Escape characters (e.g. \n ) are not processed but interpreted literally. May also contain unescaped double quotes " literally. |
let | Defines a new variable. If the variable name is already in scope, the old variable gets shadowed by the new one. |
mut | Qualifies a variable (let mut ) or a reference (&mut ) as mutable. |
:: | Serves as the path separator used to access items in a module hierarchy. For example the constant i32::MAX and the function String::from . |
as | Used to cast values between numeric types or other primitives. |
-> | Used to specify the return type of a function. It is not needed if the return type is unit () . |
Hi! Please help us improve the course!
Please consider the following statements and questions regarding this part of the course. We use your answers for improving the course.
I can see how the assignments and materials fit in with what I am supposed to learn.
I find most of what I learned so far interesting.
I am certain that I can learn the taught skills and knowledge.
I find that I would benefit from having explicit deadlines.
I feel overwhelmed by the amount of work.
I try out the examples outlined in the materials.
I feel that the assignments are too difficult.
I feel that I've been systematic and organized in my studying.
How many hours (estimated with a precision of half an hour) did you spend reading the material and completing the assignments for this part? (use a dot as the decimal separator, e.g 8.5)
How would you improve the material or assignments?