I have recently started working on the final project for the University, and I decided to do it in Rust, mainly to learn more about the language. You can try to learn a language by just reading, but the actual learning comes from actually writing code and facing the creation of a working program.
I am currently a Go developer at Sysdig, and I have to admit that I tried to write Rust code like I wrote Go. This is a mistake; both languages are very different from one another. Some things I wanted to do in Go, I couldn’t because the language is designed to be too simplistic. I understand the tradeoffs of Go, but having to write some algorithms yourself all the time, is just too tedious.
So here are some things that made me love the language for how it is designed.
Traits Link to heading
Traits in Rust can be understood as Interfaces in other languages like Go or Java. They are mostly the same, but there are some differences that, in my opinion, make Traits much more powerful. In particular, you can use Trait bounds to implement methods or other traits conditionally.
For example, let’s say you have a tuple:
struct Tuple<T> {
first: T,
second: T,
}
impl<T: PartialOrd> Tuple<T> {
fn smaller(&self) -> &T {
if self.first < self.second {
self.first
} else {
self.second
}
}
}
The Tuple<T>::smaller
method will only be available if the generic type T
provided in the Tuple implements
the PartialOrd
trait, which
is implemented, for example,
for most of the data types in the standard library, but maybe not for custom structs.
Another example would be the From
trait:
pub trait From<T> {
fn from(T) -> Self;
}
This trait allows any type that implements it to transform from type T
to this type.
For example, impl From<bool> for i64
allows to transform from bool
to i64
, so you can do:
let x: bool = true;
let y: i64 = i64::from(x);
Then I found the opposite trait: Into
.
This allows explicit transformations like:
let x: bool = true;
let y: i64 = x.into();
I thought: “Wait, isn’t this repeating the same thing? If you want to transform from bool
to i64
, you
need
to be consistent and implement both.”.
Fool of me! You don’t need to implement the Into
trait yourself! It is implemented for ANY type that
implements From
using
conditional trait bounds:
impl<T, U> Into<U> for T
where
U: From<T>
{
fn into(self) -> U {
U::from(self)
}
}
Derive clauses Link to heading
This is one of my favourite features of Rust. It allows you to create default implementation of traits for your types,
by just writing #[derive(...)]
in the type definition.
Let’s say I want to have a Tuple
type that implements the PartialEq
trait, so I can compare two tuples with ==
and !=
:
#[derive(PartialEq)]
struct Tuple<T> {
first: T,
second: T,
}
By defining the derive clause, I can now compare two tuples with ==
and !=
:
let x = Tuple { first: 1, second: 2 };
let y = Tuple { first: 1, second: 2 };
assert!(x == y);
The Rust compiler automatically generates the PartialEq
implementation for my type on compile time:
impl<T: ::core::cmp::PartialEq> ::core::cmp::PartialEq for Tuple<T> {
#[inline]
fn eq(&self, other: &Tuple<T>) -> bool {
match *other {
Tuple {
first: ref __self_1_0,
second: ref __self_1_1,
} => match *self {
Tuple {
first: ref __self_0_0,
second: ref __self_0_1,
} => (*__self_0_0) == (*__self_1_0) && (*__self_0_1) == (*__self_1_1),
},
}
}
#[inline]
fn ne(&self, other: &Tuple<T>) -> bool {
match *other {
Tuple {
first: ref __self_1_0,
second: ref __self_1_1,
} => match *self {
Tuple {
first: ref __self_0_0,
second: ref __self_0_1,
} => (*__self_0_0) != (*__self_1_0) || (*__self_0_1) != (*__self_1_1),
},
}
}
}
Pattern matching Link to heading
Another cool feature of Rust is pattern matching.
struct Color(f64, f64, f64);
impl Color {
fn as_string(self) -> String {
let Self(red, green, blue) = self;
format!("red: {}, green: {}, blue: {}", red, green, blue)
}
}
In this example, I have a struct Color
that has three fields: red
, green
and blue
. When calling as_string
on
this struct,
the three fields are extracted into the local variables red
, green
and blue
, and formatted into a string.
This is a minimal example, but it shows how pattern matching can be used to reduce code.
Not only can you use it in cases like this, but also for matching against enum variants:
enum WifiState {
Disconnected,
Connected { ssid: String },
Error(i64),
}
This enumeration has 3 variants: Disconnected
, Connected
and Error
, but Connected
has a field ssid
that
represents the
SSID of the network.
When calling get_connection_ssid
on this enum, the variant is extracted into the local variable state
, and the
fields are extracted
I can create a new method called get_connection_ssid
that returns the SSID of the connection if it’s connected:
impl WifiState {
fn get_connection_ssid(&self) -> Option<String> {
match self {
Self::Connected { ssid } => Some(ssid.clone()),
_ => None
}
}
}
Option Link to heading
In safe Rust, there’s no way to have null pointer dereferences. There’s no way to create a null pointer.
This is by design, and I think it’s fantastic.
Instead, you can use the Option
type to define that there’s no value.
let x_has_value = Some(1);
let x_has_no_value = None;
If you want to use the value inside the Option
, you must unwrap it first. It can be done using Pattern matching:
let x_has_value = Some(1);
match x_has_value {
Some(x) => println!("x has value {}", x),
None => println!("x has no value"),
}
It can also be checked with if let
statements:
let x_has_value = Some(1);
if let Some(x) = x_has_value {
println!("x has value {}", x);
} else {
println ! ("x has no value");
}
Which is the same as:
let x_has_value = Some(1);
if x_has_value.is_some() {
let x = x_has_value.unwrap();
println! ("x has value {}", x);
} else {
println ! ("x has no value");
}
Result and the ? operator Link to heading
In Go, normally, the functions return a tuple of the value and an error, and the error must be checked all the time:
result, err := doSomething()
if err != nil {
return err
}
fmt.Println(result)
I find this to be extremely verbose, and sometimes I find myself writing production code consisting of 50% error
checking. In Rust, methods that can fail return a Result
type.
fn do_something() -> Result<i32, SomeCustomError>
The equivalent code in rust from the Go code would be:
let computation = do_something();
if let Ok(result) = computation {
println!("computation value: {}", result);
} else if let Err(error) = computation {
return Err(error.into());
}
This still is very verbose when using if let
and pattern matching.
Hopefully, we have the ?
operator to return the error to the caller:
let result = do_something() ?;
println!("computation value: {}", result);
You can even write:
println!("computation value: {}", do_something()?);
Isn’t it great? 🤓
Iterators Link to heading
The Iterator pattern is a common design pattern in computer science that allows traversing a container independently of the type performing some other operations on the elements.
In Rust, you can implement your own Iterator very easily by just implementing
the Iterator
trait. In particular, you only need to
implement
the Iterator::next(&mut self) -> Option<Self::Item>
method.
The rest of the methods to work with iterators
are implemented for you, based on the first
one.
Working in Go, I’ve been missing this pattern quite a lot. In Go, you always use for
to iterate over things,
whether they are a slice, a map, or a channel.
So, in the end, you always end up doing:
for index, value := range someCollection {
// use index and/or value
}
That’s it. You need to implement everything yourself. Do you want to retrieve the sum of the first three elements in a slice higher than 0? There you go:
sum := 0
elementsLeftToSum := 3
for _, element := range someSlice {
if element > 0 {
sum += element
elementsLeftToSum--
if elementsLeftToSum == 0 {
break
}
}
}
Do you want to do the same in Rust? Easy:
some_slice.iter().filter( | element| * * element > 0).take(3).sum()
Needless to say, this is a time saver and ends up in better maintainable code.
Ownership, Borrowing and Lifetimes Link to heading
For me, this is the killer feature in Rust. This is what makes this programming language so powerful, making it more secure than other languages while maintaining performance without a Garbage Collector.
Every variable has an owner. When you declare a new value of a type, the variable that holds it is the owner of the value. There can be only one owner at the same simultaneously, and when the owner goes out of the scope, the value is dropped.
Now, in order to use it, you need to pass this value around, but there are two main ways of doing so:
- Giving away ownership to another variable.
- Lending the value to another variable that will return the ownership. This is borrowing, and it’s done by sending a reference to the actual value.
Let’s say you have this code:
let my_var = String::new("AwesomeValue");
do_something_owning(my_var);
The function do_something_owning
is acquiring ownership of the String "AwesomeValue"
.
From this function call onwards, you cannot use my_var
anymore because it is no longer valid,
and trying to use it again will end up in a compilation error.
Do you want to call it multiple times? Do not give it ownership; just borrow it:
let my_var = String::new("AwesomeValue");
do_something_borrowing( & my_var);
do_something_borrowing( & my_var);
do_something_borrowing( & my_var);
Looking at this code example, it’s clear that we are not sending the value itself, but a reference to the actual value (A reference in Rust is like a pointer in C that’s known to always be valid and correctly aligned).
So, when sending the reference to the value, we are borrowing it. But then, another set of rules enters the stage. At any given time, you can have either:
- One mutable reference
- Any number of immutable references.
References are always checked with the corresponding lifetimes of the variables they point to. In C this code compiles but is not correct:
int* evil_function_returning_dangling() {
int my_var = 42;
return &my_var;
}
When executing it, it’s (obviously) killed by the OS with Segmentation fault (core dumped)
because
dereferencing a dangling pointer is Undefined Behavior.
Same code in Rust:
fn evil_function_returning_dangling() -> &i32 {
let my_var = 42;
return &my_var;
}
It fails to compile with the following error:
error[E0515]: cannot return reference to local variable `my_var`
--> src/main.rs:3:12
|
3 | return &my_var;
| ^^^^^^^ returns a reference to data owned by the current function
For more information about this error, try `rustc --explain E0515`.
The code will never compile because it’s not valid. More info at E0515.
This prevents the existence of data races, null pointers, and dangling pointers at compile time.
To wrap it up, my experience with Rust has been incredibly positive. The language’s features such as traits, automatic trait derivation, pattern matching, option type, simplified error handling, expressive iterators, and ownership/borrowing have impressed me greatly. Rust has become my language of choice, as it offers a powerful and secure programming experience.