Motivation
I've decided to learn Rust over the course of next year, and I'll start by working on the problems of Advent of Code, a popular website that's trendy on reddit for learning new languages, so it's perfect for this task.
I will be following the official Rust documentation and trying to improve my code style as I go, on my way from zero to hero with Rust.
Let's look at the problem statement.
The problem
Here's the statement of problem 1 of Advent Of Code:
Santa has become stranded at the edge of the Solar System while delivering presents to other planets! To accurately calculate his position in space, safely align his warp drive, and return to Earth in time to save Christmas, he needs you to bring him measurements from fifty stars.
Collect stars by solving puzzles. Two puzzles will be made available on each day in the Advent calendar; the second puzzle is unlocked when you complete the first. Each puzzle grants one star. Good luck!
The Elves quickly load you into a spacecraft and prepare to launch.
At the first Go / No Go poll, every Elf is Go until the Fuel Counter-Upper. They haven't determined the amount of fuel required yet.
Fuel required to launch a given module is based on its mass. Specifically, to find the fuel required for a module, take its mass, divide by three, round down, and subtract 2.
For example:
For a mass of 12, divide by 3 and round down to get 4, then subtract 2 to get 2.
For a mass of 14, dividing by 3 and rounding down still yields 4, so the fuel required is also 2.
For a mass of 1969, the fuel required is 654.
For a mass of 100756, the fuel required is 33583.
The Fuel Counter-Upper needs to know the total fuel requirement. To find it, individually calculate the fuel needed for the mass of each module (your puzzle input), then add together all the fuel values.
What is the sum of the fuel requirements for all of the modules on your spacecraft?
As we can see, the problem itself is quite straightforward:
We have to read numbers from a file, perform some transforming operation on each number, and sum all those values together.
However, for such a simple problem, there's already quite a lot to unpack, let's see:
- We need to read an input file;
- We need to be able to interpret its contents as being "lines of integer numbers";
- We then need to transform each number, and keep a running sum of the transformed values until the end of file, and output that as our answer;
Let's look at each of these individual operations in more detail.
Read an input file
Reading the input file is the first step in solving this problem.
Firing up a Google search with: "read file in rust", leads us to the official docs, where we can grab and adapt this code:
use std::env;
use std::fs;
fn main() {
let args: Vec<String> = env::args().collect();
let filename = &args[1];
println!("In file {}", filename);
let contents = fs::read_to_string(filename)
.expect("Something went wrong reading the file");
}
Let's go over this line by line:
let
- the primary use for the let keyword is in let statements, which are used to introduce a new set of variables into the current scope, as given by a pattern. The pattern is most commonly a single variable, which means no pattern matching is done and the expression given is bound to the variable. Apart from that, patterns used in let bindings can be as complicated as needed, given that the pattern is exhaustive.
As a side-note, all variables in Rust are immutable, and the keyword mut
needs to be explicitly written to make a variable mutable.
The syntax can follow the pattern:
let <identifier>: type = expr
where the type represent the set of values that this variable can have, as bounded by its type, and expr
can be a value, or expression that evaluates to a value of type type
.
let args: Vec<String> = env::args().collect();
let filename = &args[1];
These lines set the value of a variable called args
of type Vec<String>
(a vector of strings) to the value of the command-line arguments passed to our program.
env::args()
, returns an iterator that is then collect()
ยดed into a vector of Strings, containing each of the command-line arguments passed to our program.
Then, we create a new variable, filename, that will contain the name of our input file.
Note that this is done using the ampersand before accessing the vector.
These ampersands are references, and they allow you to refer to some value without taking ownership of it.
Because we have Strings, and they work differently from other variables (not only in Rust, but in many other languages), in the sense that they are immutable by default, it means that Strings are never copied when assigned to a new variable but instead, the references to the strings are updated. In fact, if we remove the ampersand from the example above, we will get the following error:
let filename = args[1];
| ^^^^^^^
| |
| move occurs because value has type `std::string::String`, which does not implement the `Copy` trait
| help: consider borrowing here: `&args[1]`
As we can see, a move occurs, in the sense of a move within a memory location, where we are forcefully trying to extract the value on the position 1 of the args vector into the filename variable, which doesn't work.
Instead, we can just borrow the value: we can refer it, without really "owning" it, and its ownership stays with the vector or arguments. By doing this, we do not need to move it nor copy it, and we can then refer to the filename
variable as an alias to the first element of the vector.
Note that we use the index 1 instead of 0, as the first argument is typically the path of the executable.
If we create a file called: test.txt
with the contents:
1
2
3
and place it in the same directory of our program, we can then extend our program to print the contents of the file, as a string:
fn main() {
let args: Vec<String> = env::args().collect();
let filename = &args[1];
println!("In file: {}", filename);
let contents = fs::read_to_string(filename)
.expect("Something went wrong reading the file");
println!("{:?}", contents);
}
which will print:
In file: test.txt
"1\n2\n3\n"
To compile a Rust program, we can use the Rust compiler, rustc
:
rustc main.rs
This will generate an executable, named main, which we can then execute as:
./main test.txt
Note that this is not the best way to manage large Rust projects, as we have a build manager system called cargo, that's much more suited for larger projects and it helps with compiling and managing dependencies, but, for now, this will do.
The line println!("{:?}", contents);
simply prints the contents of the file as a string. The "{:?}"
is a display formatter to tell Rust how to print certain values to the console.
Interpreting the file contents as numbers
In order to sum the values, we need to treat them as numbers, not as strings.
To do this, we can parse the strings into numbers, by splitting the string by newline characters and parse each of the values. We do this by using the following line:
let v: Vec<i32> = contents.split("\n").filter_map(|w| w.parse().ok()).collect();
Let's dissect it:
We first split the file contents (a string) by the newline character, so we essentially get an iterator of strings.
We apply then the filter_map
function to it, with the following function:
|w| w.parse().ok()
the argument is in-between the |
characters and its followed by the function expression, which can be read and understood as plain English, we try to parse a certain word, w, into an integer, and we will filter out the words that fail to parse, and keep the ones that are ok()
.
Afterwards, we collect the parsed values into a vector of integers (represented in Rust by i32
).
This is how to interpret the contents of the file as integers.
Let's finish it and sum the transformed values.
Transforming and summing the values
In order to transform the values, we can apply a mapping operation that performs the requirements of the problem, after the mapping to transform the values into integers:
let v: Vec<i32> = contents.split("\n").filter_map(|w| w.parse().ok()).map(|x: i32| x/3 - 2).collect();
Finally, in order to get the answer to the problem, we need to create a new variable to hold the value of the result:
let tot: i32 = v.iter().sum();
We first need to get an iterator on the vector by using iter()
and then we can call the built-in sum()
function on it to get the value we want.
Final program
The complete program looks as follows:
use std::env;
use std::fs;
fn main() {
let args: Vec<String> = env::args().collect();
let filename = &args[1];
println!("In file: {}", filename);
let contents = fs::read_to_string(filename)
.expect("Something went wrong reading the file");
let v: Vec<i32> = contents.split("\n").filter_map(|w| w.parse().ok()).map(|x: i32| x/3 - 2).collect();
let tot: i32 = v.iter().sum();
println!("{:?}", tot);
}
We learned about how to declare variables, mutable variables (by learning about mut keyword via the documentation), how to read the contents from a file as a string, how to apply both type and value transformations on those values, and how to print results to the console, as well as how to compile and run a simple Rust program!
That's a lot of added value from such a simple program, as often is the case when starting out on a new language.
Over time, and by doing new problems, my understanding will grow, and so will the quality of my Rust code. Stay tuned!
And a Merry Xmas to all!!!
Top comments (4)
Great write up! I actually started the same journey yesterday by doing some of project euler and AOC. Iโm really starting to enjoy Rust more and more and spent today implementing a basic REST API using Actix-web and the Diesel ORM. There seems to be a truly thriving community ๐
Thanks a lot! I think it's very well-justified as languages that are community driven always attract the smartest people and the very intimate relationship with the von Neumann memory model blended with all the functional stuff is simply a work of art
That's true, I could have done it. I just adapted the choice from the possibilities docs and along the way I learnt about the iter() and collect() :)
Could avoid allocating the temp vec by doing a fold left, even