Learnings from Advent Of Code Day 1 as a Rust Newbie

Learnings from Advent Of Code Day 1 as a Rust Newbie

I've always been curious to learn a little bit more about Rust and its properties, so I decided to take part in the Advent Of Code challenge and do it in Rust! In this post, I'm going to share my learnings about Rust as a newbie going through the first challenge in Advent Of Code. Here is the day1 challenge: adventofcode.com/2022/day/1. I'd encourage looking through it so that you can understand what the code below is intended to do.

First, here is the code that you can look over:

use std::collections::BinaryHeap;
use std::fs::File;
use std::path::Path;

/*
    BufRead isn't explicitly mentioned anywhere in the file, why do we need it imported?
    Additionally, if it's not included, then `lines` method throws an error
    I see that this has something to do with traits...
    Reading: https://doc.rust-lang.org/book/ch10-00-generics.html
    - Traits are similar to interfaces in other languages, but there are some differences - according to Rust Book
    - They can have default implementations
    - Traits can be parameters (any object that implement's that trait can be passed as a paramter)

    BufReader implements the BufRead trait:
    - https://doc.rust-lang.org/std/io/struct.BufReader.html#impl-BufReader%3CR%3E
    - https://doc.rust-lang.org/src/std/io/buffered/bufreader.rs.html#55-96

    Why do traits need to be imported in Rust?
    - https://stackoverflow.com/questions/25273816/why-do-i-need-to-import-a-trait-to-use-the-methods-it-defines-for-a-type
*/
use std::io::{BufRead, BufReader};

fn main() -> Result<(), std::io::Error> {
    /*
    What is the '?' operator in Rust?
    - https://stackoverflow.com/questions/42917566/what-is-this-question-mark-operator-about
    - https://www.becomebetterprogrammer.com/rust-question-mark-operator/#:~:text=operator%20in%20Rust%20is%20used,or%20Option%20in%20a%20function.
    */

    /*
    Related: Exceptions in Rust
    https://doc.rust-lang.org/book/ch09-00-error-handling.html

    Two types of errors: recoverable and unrecoverable.
    - Recoverable errors are where you just want to report the error to the user and retry the operation
    - Unrecoverable errors are things like accessing beyond the end of an arraya and you immediately want to stop the program

    For recoverable errors, there is type Result<T, E> and panic! macro for non-recoverable errors
    Result is a type that represents either success or failure: https://doc.rust-lang.org/std/result/enum.Result.html#:~:text=Result%20is%20a%20type%20that,the%20module%20documentation%20for%20details.
    T contains the success type and E contains the Error type.
    enum Result<T, E> {
        Ok(T),
        Err(E),
    }
    When handling the Result<T, E> return type, it's common to use the match keyword like so:

    match result {
        Ok(success) => success,
        Err(error) => panic!(error),
    }

    For unrecoverable errors, there is type panic!. You can either call this explicitly in the code, or by doing something bad like accessing the end of an array.
    You can have Rust show a stack trace of the panic by setting an environment variable RUST_BACKTRACE = 1
    */

    let path = Path::new("day1.txt");
    let file = File::open(&path)?;
    let reader = BufReader::new(file);

    let mut curr_sum = 0;
    let mut heap = BinaryHeap::<i32>::new();
    /*
    lines() is a function implemented by the BufRead trait. BufReader implements the BufRead trait.
    */
    for line_result in reader.lines() {
        /*
        Calling a function on an object changes the object's ownership
        Assignment leads to ownership and re-assignment also moves ownership, read here to understand why: https://doc.rust-lang.org/book/ch04-01-what-is-ownership.html#ways-variables-and-data-interact-move
        However, if a value implements the Copy trait, then the value is copied

        https://depth-first.com/articles/2020/01/27/rust-ownership-by-example/
        String ownership examples: https://doc.rust-lang.org/book/ch04-01-what-is-ownership.html#the-string-type
        */

        /*
        References:
        If you don't want to copy, but you also don't want to change ownership, you can borrow with the & character
        If a function takes in a reference, the function doesn't own the value so it won't drop it upon completion of the function's execution
        References are immutable by default, but adding the `mut` keyword makes it so that the reference is modifiable.
        There is one big caveat which is that if you have a mutable reference to a value, you can't have any other references to that value.


        https://doc.rust-lang.org/book/ch04-02-references-and-borrowing.html
        */

        let line = line_result?;

        /*
        Why can we call this function but still access `line` in the else statement?
        Because .len takes in a string reference, so line is borrowed, not moved.
        Ownership does not change as a result
        */
        let is_new_line = line.len() == 0;
        if is_new_line {
            /*
                What is the ! operator on println and why don't I need to import println via use?
            */
            heap.push(curr_sum);
            curr_sum = 0;
        } else {
            /*
            What is unwrap? Give me the result and if there's an error, then panic. It's somewhat equivalent to:
            match line {
                Ok(line) => line,
                Err(e) => panic!("Error"),
            }
            */
            curr_sum += line.parse::<i32>().unwrap();
        }
    }
    const TOP_K_ELVES: i32 = 3;
    let mut total_calories = 0;
    for _ in 0..TOP_K_ELVES {
        total_calories += heap.pop().unwrap();
    }
    println!(
        "{:?} total calories were retrieved by the top {:?} elves",
        total_calories, TOP_K_ELVES,
    );
    Ok(())
}

I've included comments in various sections describing some of the things I learned and was curious about.

Let's walk through the code, highlighting the interesting parts.

Main function

Line 24 declares the main function. This is the entry point to a Rust program. The keywords after -> indicates the return value of the function. In this case, the return value is of type Result. It looks like this:

enum Result<T, E> {
   Ok(T),
   Err(E),
}

This is a very common type in Rust that you'll see everywhere. I'll explain why in a following section.

In the Result enum, Ok implies the success response and Err implies the Error type. In our case, the success return type is () which is known as the unit primitive: doc.rust-lang.org/std/primitive.unit.html. It's similar to the void type in other languages. The error type is of type std::io::Error.

Reading A File

To solve the challenge, we must first read in a file of information. I downloaded the file and saved it in my repo under day1.txt. The next step is to use Rust to read the file.

I use the std::path::Path module in the Rust standard library to do this. To do that, I first reference the module as a use declaration above to tell the compiler that I'm going to be using symbols from that module. The Path::new function returns a reference to a path object that can be passed into File::open for reading. I'll talk about references in a different post.

Next, I pass in the Path reference to the File::open function which, if you look at the docs, returns a std::io::Result type. This type is just a shortcut for the std::result::Result type that we saw earlier in the main function - it's a very common return type in Rust.

? Operator

The interesting thing about this line of code is the ? mark operator at the end of the File::open function call. What does it do?

The ? operator is specific to Result or Option function return types. It causes the function to return with the error if there is any error in the File::open call. If there isn't an error, in this case, it'll return the success type of the Result enum which is a File object.

When used on the Result type, it operates similarly to the following code:

let file = match File::open(&path) {
    Ok(file) => file,
    Error(error) => return Error(),
}

This code returns a File object if File::open succeeds. If there is an error in it, then the main function returns with the std::io::Error that File::open may throw. When we use the ? operator, the function must return a Result with that Error type.

Reading Line-By-Line

Now that we have the plumbing to read the file, let's go through it line-by-line to solve the challenge. We will use the BufReader struct to do this. The struct implements a trait called BufRead and this trait has a method lines() which we can use to iterate through each line of the file. A trait in Rust is similar to interfaces in other languages. The interesting thing is that we need to call use std::io::BufRead to tell the compiler that we want to use this trait. Otherwise, it'll throw an error saying that the lines() method does not exist on the struct BufReader.

The reader.lines() call returns a Result<String, std::io::Error> type. To get the String value out of the result, we can, just like we did previously, use the ? operator. Note that the error type in Result<String, std::io::Error> is the same as the error type when we used the ? operator on the File::open call which is why we can use the ? operator here as well.

Now that we're able to read each line, we implement the logic to solve the challenge. I'm not going to talk too much about the logic as this post is intended to talk about Rust, not the challenge.

Converting a String to an i32

line.parse::<i32>() is a function you can use to convert a String to an i32. Its return type is Result<i32, ParseIntError>. I initially tried to pull the i32 out of the Result return type using the ? operator, but the compiler wouldn't let me. The reason is because, when using the ? operator, upon an error of the parse method, the main function would return with error ParseIntError. Right now, the main function is returning an error of type std::io::Error which is a different type from ParseIntError.

So, what is another way that we can pull the i32 out of the Result<i32, ParseIntError> type? Similar to what we discussed earlier, we can use the match keyword like so:

curr_sum += match line.parse::<i32>() {
    Ok(val) => val,
    Err(e) => panic!("Error trying to turn the string to an int"),
}

This snippet will return the i32 if it's able to convert the String to an i32. If not, it will panic!. What is a panic? A panic is an unrecoverable error that will cause the program to exit immediatly.A short-hand of the above match snippet is to use the unwrap method.

Conclusion

These are some of the interesting things I learned about Rust in the first challenge in Advent Of Code. I also learned a bunch about ownership, references, and borrowing which I'll save for a different post since those are much longer topics.