Rust intro
In this tutorial, I'll assume that you have some programming experience. I'll also assume that you use programming as a tool and that you don't necessarily have a background in computer science. Our focus will be more on the "hows" rather than the "whys," whenever possible.
By the end of this tutorial, you will have gained enough knowledge of Rust to begin developing high-performing code. I encourage you to work through the examples at your own pace, attempting to solve issues before reviewing the solutions. For your convenience, unanswered problems are available on GitHub, with the solutions provided in the "solutions" branch.
What is Rust?
Rust stands out as a high-performance and memory-efficient programming language that emerged from the endeavors of Mozilla's research employees (Rust, not Firefox, is Mozilla’s greatest industry contribution). Prioritizing performance and memory safety, Rust utilizes a robust type system and an innovative "ownership" model to guarantee memory safety and thread safety at compile time. This approach aims to address vulnerabilities arising from memory errors, with estimates from Microsoft suggesting that approximately 70% of code vulnerabilities stem from memory-related issues (source).
Rust's ability to produce fast, efficient, and resilient code has catapulted it to the top of the list as the most admired language amongst developers. The community of Rust programmers affectionately refers to themselves as "Rustaceans," and the language's unofficial mascot, Ferris the crab:
Rust Compared to Python
When comparing Rust to a language like Python, several key differences become apparent:
-
Performance: Rust is renowned for its high performance, often being comparable to languages like C or C++. Python, on the other hand, tends to be significantly slower than Rust, emphasizing ease of development over raw performance.
-
Type System: Python is a dynamically typed language, meaning the interpreter infers variable types at runtime, allowing flexibility but increasing the potential for type-related errors. In contrast, Rust requires variables to have known types at compile time, enhancing safety and allowing for optimizations to be made by the compiler.
-
Compilation: Rust is a compiled language, while Python is interpreted. Python code is executed by an interpreter, converting code to bytecode at runtime, resulting in slower performance. Rust, as a compiled language, produces machine code binaries before runtime, reducing overhead and enabling compiler optimizations for faster, more memory-efficient execution.
-
Memory Management: Python employs a "garbage collector" to manage memory by periodically checking and freeing memory occupied by variables that go out of scope, impacting speed and memory efficiency. Rust utilizes an "ownership" memory model enforced by the "borrow checker" at compile time. Each variable in Rust has a single owner, and memory is automatically freed when the owner goes out of scope. This approach, without a costly garbage collector, contributes to Rust's fast runtime.
-
Thread Safety: Python's Global Interpreter Lock (GIL) allows only one CPU-bound thread to execute at a time, ensuring safety across threads but causing a bottleneck for parallel code execution. In Rust, the ownership model, combined with allowing either numerous immutable references or a single mutable reference at a time, guarantees thread safety at compile time without the restrictions posed by a GIL.
-
Package Management: Both Python and Rust use package management systems to handle external libraries or crates (in Rust). Rust utilizes the
cargo
package manager andtoml
files to manage project dependencies, while Python uses tools likepip
andrequirements.txt
to manage packages.
Rust and Python employ different approaches to achieve their goals, with Rust focusing on performance, memory safety, and concurrency, whereas Python emphasizes ease of use and flexibility.
Installing Rust
Comprehensive installation instructions for Rust can be accessed here. The installation process involves utilizing rustup
, a tool used for installing both the Rust compiler (rustc
) and the package manager (cargo
). These tools are compatible with Linux, macOS, and Windows (WSL).
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
For alternative methods of installing Rust, refer to this page.
For this tutorial, we'll utilize the official Rust Docker image to compile and run code within a container. To pull the image, execute:
docker pull Rust
docker run -it --rm -v $(pwd):/local_data -w /local_data Rust bash
run
executes the bash command in an interactive mode (-it
) to provide an interactive Bash shell for work.--rm
ensures the container is deleted after use.-v $(pwd):/local_data
mounts the current directory on the local machine to/local_data
in the container.-w /local_data
sets the working directory to/local_data
within the container.
(Free) Learning resources:
- "The Book", the offical
- "Rustlings Course on GitHub"
- "Offical Website"
- "Rust Playground" a web coding enviroment for Rust
- "Rust Standard Library Crate"
- "Command line apps in Rust"
- "The Embedded Rust Book"
Basics of Rust
In this section we will cover the basics of Rust.
Hello World
To create a new project in Rust, utilize the cargo
command:
cargo new hello
hello
.
-> ls -ah hello
. .. .git .gitignore Cargo.toml src
When using cargo new
, a new Rust project is initialized. Alongside creating the project structure, cargo
automatically sets up a new Git repository for the package and adds a Rust-specific .gitignore
file.
The newly created project includes a Cargo.toml
file, which serves as the manifest file for the project.
This file contains details about the project, including external dependencies, package name, and versions used.
-> cat hello/Cargo.toml
[package]
name = "hello"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.Rust-lang.org/cargo/reference/manifest.html
[dependencies]
src
directory with a main.rs
file containing an example program that will print "Hello, world":
hello.rs | |
---|---|
1 2 3 |
|
In this example, the following points are illustrated:
- Functions in Rust are defined using the
fn
keyword. - The
main
function designates the entry point of the code to the compiler. - Code blocks are enclosed within
{}
to denote scopes. - Rust includes macros (indicated by
!
, which will be covered later) likeprintln!
used to print the string"Hello, world!"
to the screen. - Statements in Rust are terminated with a
;
(exceptions for when to omit the;
will be explained later).
This example can be compiled using rustc
:
rustc src/main.rs -o main
to create the executable main
.
Alternatively we can use cargo build
to compile:
-> cargo build
Compiling hello v0.1.0 (/local_data/hello)
Finished dev [unoptimized + debuginfo] target(s) in 0.28s
target/debug/hello
. To run the executable, you can either call the executable directly or use the cargo run
command. When using cargo run
, if there are changes in the code or if the code hasn't been compiled previously, it automatically triggers the cargo build
command before executing the program.
-> cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.01s
Running `target/debug/hello`
Hello, world!
The executable is typically found within a debug
folder. By default, Rust generates debug information useful for code analysis and debugging. To create an optimized version for end-users, the --release
flag can be utilized:
-> cargo build --release
Compiling hello v0.1.0 (/local_data/hello)
Finished release [optimized] target(s) in 0.25s
This will take longer to compile as rustc
is optimizing the code.
Types in Rust
In Rust, types must be known at compile time. You can explicitly specify the type of a variable using the syntax let my_variable: type = value
, where the type
is specified after the variable name using a :
. The following example demonstrates explicit declaration of variable types on lines 3-6:
types.rs | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
|
The Rust compiler features type inference, enabling omission of the variable type, as it can deduce the type based on the assigned value. Internally, the compiler determines the variable's type during compilation based on the provided value. An example illustrating this behavior is demonstrated in lines 9-12 of types.rs
.
Additionally, we can explicitly specify the variable type by adding ::<type>
after the assigned value. This method is showcased in lines 16-18 of types.rs
.
In Rust, type conversion between different types can be achieved using keywords such as into
, try_into
, from
, try_from
, or as
. Below are some examples:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
|
Ownership in Rust
Ownership and the borrow checker constitute the foundation of Rust's memory management. When dealing with ownership in Rust, it's essential to remember three fundamental rules:
- Every value in Rust has a designated owner.
- At any given time, there can only be a single owner for a value.
- When the owner goes out of scope, the associated value is automatically dropped.
Let's delve into an example to illustrate this concept:
Ownership Example | |
---|---|
1 2 3 4 5 6 7 8 9 10 |
|
In the provided code, a new variable x
of type String
is created. At line 5, a new variable y
is assigned the value of x
. Subsequently, attempts to print x
and y
on lines 6 and 8, respectively, would result in a compilation error:
error[E0382]: borrow of moved value: `x`
--> src/main.rs:8:20
|
3 | let mut x = String::from("Hello");
| ----- move occurs because `x` has type `String`, which does not implement the `Copy` trait
4 |
5 | let y = x;
| - value moved here
...
8 | println!("{}", x);
| ^ value borrowed here after move
So what's happening? Well on line 5 we changed the ownership of the part of the memory that holds "Hello". The ownership of this has changed from x
to y
. Since we can only ever have one owner at a time, x
cannot be printed. We could however run this example:
Ownership Example Corrected | |
---|---|
1 2 3 4 5 6 7 8 9 10 |
|
y
we have passed ownership back to x
.
The fact that all value in Rust only ever has one owner guarentees that we can never acidently drop or delete a value that is still in use. This might seem very limiting and a heavy cost to pay for safety, but we can use "borrowing" to circumvent this issue.
Ownership Example Corrected | |
---|---|
1 2 3 4 5 6 7 8 9 10 |
|
In the above value we have "borrowed" the value of x
. By borrowing the values instead of taking ownership, x
maintains ownership over the value, allowing different parts of the code to access the value of the value.
When borrowing values Rust's "borrow checker" will keep track of all refeneces and make sure that we don't have dangling references or data races. Consider the following:
Mutable and immutable borrows | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 |
|
x
is established in line 5. However, an attempt to modify x
occurs in line 8, resulting in a compilation error:
Compiling tutorial v0.1.0 (/local_data)
error[E0502]: cannot borrow `x` as mutable because it is also borrowed as immutable
--> src/main.rs:8:5
|
5 | let y = &x;
| -- immutable borrow occurs here
...
8 | x += ", world";
| ^^^^^^^^^^^^^^ mutable borrow occurs here
9 | println!("{}", x);
10 | println!("{}", y);
| - immutable borrow later used here
For more information about this error, try `rustc --explain E0502`.
+=
operator, used to alter the value of x
, takes a "mutable" reference to x
and then assigns the modified value back to x
.
Essentially, the +=
operator takes ownership of x
's value momentarily and then returns it to the variable x
.
Rust enforces a rule allowing only one mutable reference or any number of immutable references at any given time. This constraint aligns with memory safety principles: preventing a scenario where one part of the code attempts to modify a value while another part tries to read it. Such a situation could lead to a race condition, causing the code's behavior to be undefined and reliant on the order of execution. While it might not appear problematic for sequential code like this, attempting read and write actions across different threads could result in significant issues.
So how can we work with mutable and immutable references? Consider the following example:
Mutable and immutable references | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
|
In this code snippet, we perform several operations with mutable and immutable references to showcase Rust's ownership and borrowing principles.
- Line 3 initializes a mutable
i32
assigned to variablex
. - Line 5 creates an immutable reference
y
to the value ofx
. - Line 9 assigns a new value to
x
. This operation works because we only have a single immutable reference,y
. Asi32
can be copied,z
receives a copy of the value ofx
, not the actual value.
In lines 17-22, a new scope is created. Here, we transfer ownership of reference y
to a
and establish a second immutable reference, another
, to x
. Remembering the three ownership rules ("When the owner goes out of scope, the value will be dropped"), when the scope ends at line 22, the values of a
and another
are dropped. Since a
took ownership of y
, there are now 0 immutable references. Any attempt to access y
would result in an error.
In lines 27-32, a new scope introduces a mutable reference b
to x
. At this point, there are 0 immutable references and 1 mutable reference. Modification of the value behind x
is possible by "dereferencing" b
, illustrated in line 29 (*b += 10;
), which adds 10 to the actual value b
is referencing. When this scope ends at line 32, b
is dropped, leaving 0 immutable references and 0 mutable references.
Finally, lines 35 and 36 create a mutable reference to x
and modify its value.
Throughout this example, x
remains the sole owner of the value, never relinquishing ownership. Borrowing the value (y
, a
, another
, b
, last
) occurs at multiple stages, but x
retains ownership. Although y
initially held an immutable reference to x
, preventing last
from taking a mutable reference, ownership of the reference shifted from y
to a
. Upon a
's scope exit, the immutable reference was dropped. Throughout this code, multiple immutable references or a single mutable reference were consistently present.
Understanding ownership and borrowing is the most challenging concept in Rust. Proficiency in these concepts is crucial for mastering Rust.
Functional Programming
In Rust, functional programming can be achieved through two primary methods: using functions defined with the fn
keyword or leveraging closures.
Functions, declared using the fn
keyword, represent a fundamental approach to functional programming in Rust. They encapsulate blocks of code that can be called multiple times with different arguments.
Closures, on the other hand, are more powerful and flexible. They are similar to functions but can capture variables from their surrounding environment. Closures allow for defining anonymous functions on the fly, making them highly adaptable for tasks requiring flexibility in behavior and data encapsulation.
Both functions and closures play integral roles in enabling functional programming paradigms within Rust, offering different levels of flexibility and usability in various scenarios.
Functions
Functions in Rust are defined using the following syntax:
Examples of functions | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
|
In the example above, three functions are defined using the fn
keyword to indicate their creation. When defining functions, specifying the data types of passed arguments is necessary, as demonstrated here by using i32
types in all cases. Additionally, if a function returns a value, explicit declaration of the return type is required. Lines 1 and 5 explicitly define the return type as i32
, denoted by -> T
, where T
represents the data type.
Lines 9 and 13 introduce functions that do not return any value. When a function doesn't return anything, the ->
can be omitted. Alternatively, it's possible to explicitly state the absence of a return value using -> ()
.
The functions add_numbers
and multiply_numbers
both return an i32
. However, only add_numbers
uses a return
keyword. In Rust, if a statement isn't followed by a ;
, it's assumed to be the return value. In the case of multiply_numbers
, the absence of ;
specifies that the function should return a * b
.
It's important to note that in all these functions, ownership of a
and b
is taken within the functions. Consequently, when the function's scope ends, both a
and b
are dropped. While this behavior might not be problematic for i32
due to its copy trait, allowing passing a copy of the value rather than the value itself, it's a crucial consideration for other types where ownership might cause different behavior.
Consider the following example:
Problems with borrowing | |
---|---|
1 2 3 4 5 6 7 8 9 |
|
This will give the following error:
error[E0382]: borrow of moved value: `my_string`
--> src/main.rs:9:20
|
7 | let my_string = String::from("Save Ferris!");
| --------- move occurs because `my_string` has type `String`, which does not implement the `Copy` trait
8 | print_string(my_string);
| --------- value moved here
9 | println!("{}", my_string);
| ^^^^^^^^^ value borrowed here after move
|
print_string
receives my_string
, it assumes ownership. To address this, we have two solutions: either use the clone
method when passing my_string
to print_string
, or modify print_string
to borrow the string by taking a reference instead. The corrected code would appear as follows:
Examples of functions with borrowing | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
Functions can also return tuples. Consider the following:
Example of function returning a tuple | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
In the example above, a tuple of type (i32, f32)
is returned. Line 7 stores the tuple as a variable, while on line 9, explicit deconstruction of the tuple occurs, assigning its elements to variables y
and z
. Accessing elements of the tuple can be achieved using tup.n
to retrieve the nth element.
Closures
Closures in Rust bear similarities to lambda functions found in other programming languages. They offer a concise means to create short blocks of functionality within code. Closures, like functions, can capture and manipulate variables from their enclosing scope. They are defined using the |argument| { body }
syntax, where argument
represents parameters and body
signifies the functionality of the closure.
An example of a closure definition:
Examples of Closures | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 |
|
In lines 4 and 6, two closures are defined. The area
closure accepts a variable x
and computes the area of a circle with radius x
. This closure borrows the value of pi
for the duration of its scope. On the other hand, the print_area
closure accepts a variable x
, prints a statement, and then passes a copy of x
to the area
closure.
Flow Control
If statements
Rust's if
statements follow the subsequent syntax:
if statements | |
---|---|
1 2 3 4 5 6 7 8 9 |
|
Note that an if
block must start with an if
statement and may have only one if
branch and at most one else
branch. However, multiple else if
branches can be included as needed.
if
statements are also capable of assigning variables or returning values. Let's consider the following example:
returning if statements | |
---|---|
1 2 3 4 5 6 7 8 9 |
|
my_string
, assigned the value from this if
block. In lines 4, 6, and 8, the absence of ;
at the end of these lines allows them to return the String
type. Finally, line 9 concludes the assignment by adding a ;
at the end of the final block.
Match
match
statements in Rust are akin to switch
statements found in other programming languages. They enable pattern matching on variables, allowing for concise and comprehensive conditional branching.
Matching involves specifying the pattern to match against, which can either be a variable or a condition evaluation (e.g., x > 10
). It commences with the keyword match
and encloses different options within a set of {}
. For each pattern, code branches to run are assigned using the =>
syntax.
match statements | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
|
In lines 6 and 9, the code searches for values of x
within the ranges 0-3 and 4-10, respectively. On line 12, it checks if a
equals 3. Finally, on line 15, the default case is defined using _
. Each branch in this match
statement executes a block of code enclosed within its scope.
In the example from line 20-27 we are returning a str
based on the pattern found.
Loops
Loops in Rust are straightforward and flexible. The loop
keyword initiates an infinite loop, allowing code to execute repeatedly within a defined scope until explicitly interrupted by a break
statement.
For instance:
loop example | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
Lines 4-12 constitute the content wrapped within the loop
block, as indicated on line 4. At line 7, a continue
statement is employed to skip the iteration where i
equals 3. Moreover, line 9 utilizes a break
statement to exit the loop when the condition i > 10
is met.
In Rust, it is possible to assign labels to loops to facilitate continue
or break
operations targeting a specific loop. This is achieved using the 'name: loop {}
syntax:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
|
'astra
, encompassing the scope from line 4 to line 18. Within 'astra
, we define a nested loop named 'kafka
, spanning lines 7 to 16.
At line 8, a break
statement exits the 'astra
loop if i > 10
. Furthermore, line 11 employs a break
statement to exit the 'kafka
loop if j > 3
.
The output of this code will be:
i,j = 0,0
i,j = 0,1
i,j = 0,2
i,j = 0,3
i,j = 1,0
...
i,j = 10,2
i,j = 10,3
For Loops
For loops in Rust operate on any data that conforms to an iterator. This includes constructs such as for element in list
or for i in a range
. The syntax used for these loops is as follows:
1 2 3 4 5 6 7 |
|
0..n
, representing the inclusive range from 0 to 9 (Alternatively, we could use 0..=9
).
When dealing with an array or vector of items, we can iterate over them as follows:
1 2 3 4 5 6 7 |
|
In the above example, a
stores a copy of the values from my_arr
rather than a reference to those values. Modifying a will not alter my_arr
. However, the behavior slightly differs when working with vectors.
1 2 3 4 5 6 7 8 |
|
The above example will return an error on line 7.
--> src/main.rs:7:22
|
2 | let my_arr: Vec<f32> = vec![1.,2.,3.,43., 3.14];
| ------ move occurs because `my_arr` has type `Vec<f32>`, which does not implement the `Copy` trait
3 | for a in my_arr{
| ------ `my_arr` moved due to this implicit call to `.into_iter()`
...
7 | println!("{:?}", my_arr);
| ^^^^^^ value borrowed here after move
|
The error indicates that Vec<f32>
doesn't implement the Copy
trait. Consequently, when attempting to iterate over its values, Rust borrows the values rather than making copies. As a result, the ownership of these values is temporarily transferred into the for loop's scope at line 5. However, as the loop ends, these borrowed values are automatically dropped, as their ownership wasn't transferred back outside the loop.
Looking further at the compile output we see:
help: consider iterating over a slice of the `Vec<f32>`'s content to avoid moving into the `for` loop
|
3 | for a in &my_arr{
| +
For more information about this error, try `rustc --explain E0382`.
vector for loop | |
---|---|
1 2 3 4 5 6 7 8 |
|
We can iterate over tuples to access and combine their values:
Asigning Values in a Loop | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 |
|
In the provided example, there are three vectors: x
, y
, and z
, where z
is a mutable vector.
At line 6, iter
is utilized to obtain an iterable reference to x
. Subsequently, it is zip
ped with a reference to y
, invoking the into_iter
method for y
(similar to the vector for loop example). This action results in a tuple
of type (&f32, &f32)
. Additionally, another zip
operation is performed with the range 0..x.len()
, effectively creating a loop over a tuple
of ((&f32, &f32), usize)
.
Within this loop, values are assigned to z
.
Aside on iter
vs into_iter
In the "vector for loop" example, we employed the for a in my_arr
syntax, which implicitly calls the into_iter
method. The into_iter
method, being a generic method, returns either a copy, a reference, or the value itself. On the other hand, the iter
method explicitly returns a reference.
If distinguishing between the two seems perplexing, consider into_iter
as moving the value "into" the scope. If ownership needs to be maintained, it's advisable to use iter
. Conversely, if the value can be consumed by the scope, into_iter
is preferable.
For a more detailed explanation, refer to this Stack Overflow question.
Looping the Rust way
In the "Assigning Values in a Loop" section, we explored how to derive values from two vectors to assign to a third vector. However, this approach isn't considered very idiomatic in Rust. A more idiomatic way to achieve this would be:
Idomatic Rust For Loop | |
---|---|
1 2 3 4 5 6 7 |
|
In this example, we condense the entire loop into a single line of code. Starting with x.iter()
, we iterate over references to the values within x
. Using the zip
function with a reference to y
facilitates the iteration over a tuple of type (&f32, &f32)
.
Each tuple undergoes processing within a closure passed to the map
method. This closure deconstructs the tuple into two values and adds them together. The collect()
method accumulates the values returned by the closure used in the map
method.
The "Turbofish" syntax, collect::<type>()
, informs collect
about the desired return type. In this instance, using collect::<Vec<f32>>()
, we obtain a Vec<f32>
.
Using a reduction, as shown in the 'Idiomatic Rust For Loop' example, is a powerful tool. For instance, suppose we aim to extract all even values from a vector, we could employ:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
In the given code, values
is a range from 0 to 99 inclusive, represented as a collection of integers (i32
). The operations on this range illustrate various methods provided by Rust's Iterator trait.
Starting from line 4, even_squared
is created by cloning the values
range. The clone
method is used here to avoid consuming the original range, enabling separate iteration over the original range (values
) and the cloned range. The filter
method is then applied to this cloned range, utilizing a closure (|x| x % 2 == 0
) to test each element for evenness by performing a modulo operation and checking if the remainder is zero. Elements satisfying this condition are retained, while those failing the test are discarded. The subsequent map
method takes the retained even numbers, squares each value by multiplying it by itself (x * x
), and produces a transformed iterator. Finally, the collect
method is used to gather the squared even numbers into a Vec<i32>
.
The filter
method implicitly calls into_iter
on the cloned values
range, which temporarily takes ownership of the elements within the scope of the filter operation. After the collect
method consumes the iterator, the clone of the values
range is no longer needed and gets dropped, releasing its resources.
Next, between lines 10 and 12, odd_sum
is calculated using a similar approach. Here, the filter
method is again used on a cloned range of values
, but this time with a closure (|x| x % 2 == 1
) that filters for odd numbers. The sum
method is applied to this filtered iterator to compute the sum of the odd numbers present in the range.
The code concludes by displaying the vector containing squared even numbers (even_squared
) and printing the sum of the odd numbers (odd_sum
).
Object Orientated Programming
In contrast to languages like Python and C++, Rust diverges from class-based inheritance. It emphasizes struct composition and trait-based polymorphism. Rather than relying on class inheritance, Rust promotes struct composition, allowing structs to contain instances of other structs or types. Traits, serving as a form of polymorphism, define sets of methods that types can implement, offering shared behaviors across different types without a single inheritance hierarchy. This trait-based approach fosters modularity and flexibility while ensuring safety and performance.
Structs in Rust
Structs in Rust form the foundation of object-oriented programming (OOP). They can be seen as collections of variables that serve a related purpose or represent a specific context.
Consider the example below:
Example of a Struct | |
---|---|
1 2 3 4 5 6 |
|
We've created a struct
named Point3D
, representing a point in 3D space.
The struct
is defined by encapsulating member data within curly brackets {}
after naming it.
Within this struct
, we've defined fields such as x
, y
, and z
, each having the data type f32
, representing the coordinates in the x, y, and z axes, respectively.
Additionally, there's a field named coord_system
of type String
, serving to describe the coordinate system.
In Rust, it's common practice to separate each field with a ,
and a new line for readability.
The presence of a trailing ,
after the last field doesn't cause a compile-time error and is often used to facilitate future struct modifications.
Methods can be implemented for a struct
in Rust, functioning as functions that the struct
itself can utilize. These methods can modify the struct
, perform actions based on the field data, and more. The impl
keyword is used to define these methods:
Example of implementing structs | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
|
In the code example above, we've implemented three methods for the Point3D
struct.
-
The
new
function on line 3 creates and returns a newPoint3D
with default values at the origin (0, 0, 0). To use it:let mut my_point = Point3D::new();
-
Line 14 contains the
get_magnitude
method, which accesses the struct's data without modifying it. It takes a non-mutable reference to itself (&Self
) and returns af32
. To call it:my_point.get_magnitude()
. -
The
add_constant
method, defined on line 20, modifies thePoint3D
's data using a given value. It requires a mutable reference to itself (&mut Self
) and takes a non-mutable reference to the constant (&f32
). Usage example:my_point.add_constant(&3.14);
. By taking a reference to the constant, it prevents ownership issues and avoids unintentional dropping ofc
after line 24.
Traits
Traits in Rust provide a means to define common interfaces that can be implemented by different structs. They enable struct types to share behavior or functionality through shared methods.
For instance, let's consider the following struct:
Point2D | |
---|---|
1 2 3 4 5 |
|
The Point2D
struct shares similarities with Point3D
. It would be beneficial if these structs had some common methods. To achieve this, we can define a trait
that provides a shared interface for both structs. This approach allows for greater code flexibility and consistency. Let's explore this concept:
Trait Example | |
---|---|
1 2 3 4 |
|
The PointLike
trait defines a common interface for types that exhibit point-like behavior. For a type to be considered PointLike
, it must implement two functions:
get_magnitude
: This method calculates the magnitude of the point and returns af32
.add
: Accepts a mutable reference to itself, along with a reference to af32
, and does not return any value.
Notably, the Point3D
struct possesses a method named get_magnitude
, aligning with the trait's requirements. However, it lacks a function named add
, although it has a similar method called add_constant
. To conform Point3D
to the PointLike
trait, we can provide an implementation that satisfies the trait's functions:
Implementing Traits | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
|
PointLike
trait for the Point3D
struct, utilizing the methods we had previously defined. Extending this trait implementation to Point2D
would involve providing similar implementations for the required trait methods.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
|
In the given example, the trait implementation for Point2D
directly utilizes existing methods. When implementing traits, we have the flexibility to use pre-existing methods or define new ones. This versatility allows us to employ these implementations within our code as demonstrated.
Using traits | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 |
|
Generics
Generic types in Rust bear resemblance to C++ templates. They enable us to write versatile code that isn't tied to specific data types. This allows for more reusable and adaptable code. Consider the following illustration:
Generic Types | |
---|---|
1 2 3 4 |
|
Here we have defined a generic Point2D
struct that represents a 2D point in space. This struct is designed to work with any data type, as it utilizes the placeholder type T
for both the x
and y
coordinates. Using this placeholder type allows the struct to remain agnostic to the specific data type used for its coordinates.
The versatility of this generic struct becomes apparent when implementing methods or functionalities that can work universally across various data types.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
In the above we have implemented two functions assuming a type T
. Each return references of type T
. If we wanted to use these we could run:
1 2 3 4 5 |
|
f32
.
We can combine traits and generics to enable custom data types that possess specific traits. Consider the example below:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
Here, we've defined a struct called PlanetarySystem
that takes a generic type implementing the PointLike
trait. As the PointLike
trait includes the get_magnitude
method, this code is applicable to any generic type adhering to the PointLike
trait. This flexibility allows us to use the code as demonstrated below:
fn main(){
let mut solar_system: PlanetarySystem<Point3D> = PlanetarySystem{
planet_location: vec![
Point3D::new(),
Point3D::new(),
Point3D::new()
],
planet_name: vec![
"Mercury".to_string(),
"Venus".to_string(),
"Earth".to_string()
],
};
solar_system.planet_location[0].add(&0.39);
solar_system.planet_location[1].add(&0.72);
solar_system.planet_location[2].add(&1.);
solar_system.print_distance_from_star();
let mut trappst_system: PlanetarySystem<Point2D> = PlanetarySystem{
planet_location: vec![Point2D::new(), Point2D::new(), Point2D::new()],
planet_name: vec![ "TRAPPIST-1b".to_string(), "TRAPPIST-1c".to_string(), "TRAPPIST-1e".to_string()],
};
trappst_system.planet_location[0].add(&0.01154);
trappst_system.planet_location[1].add(&0.01580);
trappst_system.planet_location[2].add(&0.029);
trappst_system.print_distance_from_star();
}
Here, we are defining solar_system
as a PlanetarySystem
that utilizes a Point3D
data type. Subsequently, we invoke the print_distance_from_star
method, leveraging the fact that Point3D
conforms to PointLike
and therefore possesses the get_magnitude
method.
In the context of our PlanetarySystem
, the magnitude represents the distance between the planet and the origin, which we designate as the star's location. Running this code yields the following output:
Distance from Mercury to it's star: 0.39 AU
Distance from Venus to it's star: 0.72 AU
Distance from Earth to it's star: 0.99999994 AU
Distance from TRAPPIST-1b to it's star: 0.011540001 AU
Distance from TRAPPIST-1c to it's star: 0.0158 AU
Distance from TRAPPIST-1e to it's star: 0.029 AU
In this example, the PlanetarySystem
struct doesn't differentiate between using Point3D
or Point2D
.