Thanks to my sponsors: Benjamin Röjder Delnavaz, Aiden Scandella, Aleksandre Khokhiashvili, ZacJW, Guillaume E, Bob Ippolito, James Leitch, Ahmad Alhashemi, Chris, Chris Biscardi, Sean Bryant, zaurask, Johan Saf, Garret Kelly, Romain Ruetschi, genny, Ian McLinden, Zoran Zaric, Marcus Griep, compwhizii and 230 more
Day 1 (Advent of Code 2022)
👋 This page was last updated ~2 years ago. Just so you know.
Two years ago, I did part of Advent of Code 2020 using the Rust language. It was a lot of fun, so let's try it again!
The problem statement
Our input looks something like this:
1000
2000
3000
4000
5000
6000
7000
8000
9000
10000
Each group of lines separated by an empty line is a list of food items an elf is carrying: each line corresponds to the number of calories in that food.
So, the first elf is carrying three food items: one is 1000 calories, the second is 2000 calories, the third is 3000 calories. The second elf is carrying only one food item of 4000 calories, etc.
We must answer "how many calories is the elf who carries the most calories carrying?"
This'll be a short one!
Getting started
If you're planning on following along, you'll need to install a Rust toolchain. The simplest way is through rustup.
If everything is set up correctly, you should be able to run these commands in a terminal:
$ rustc -V
rustc 1.65.0 (897e37553 2022-11-02)
$ cargo -V
cargo 1.65.0 (4bc8f24d3 2022-10-20)
Cool bear's hot tip
If this is showing something older than 1.65, it might be a while since you
last did some Rust. Make sure to run rustup update
first!
Me, I've made an aoc2022
folder, initialized a Git repository in there and
pushed it to a private GitHub repo (no spoilers!). In there, I've made a new
day1
Rust crate:
$ cd aoc2022/
$ cargo new day1
Created binary (application) `day1` package
I've also created a file in aoc2022/.gitignore
with this line:
target
So that any folders or files named target
won't be pushed to GitHub.
Cool bear's hot tip
cargo new
would normally generate a .gitignore
itself (and an empty Git
repository) but it doesn't here because the outer directory is already a
repository, so it assumes you're just creating a sub-crate or something.
If you don't use Git, you can configure cargo to use another VCS, see the cargo-new.vcs configuration option.
Our final file structure looks like this:
$ tree $PWD
/home/amos/bearcove/aoc2022
├── day1
│ ├── Cargo.toml
│ └── src
│ └── main.rs
└── README.md
2 directories, 3 files
The day1
project generated is just a hello world, let's run it:
$ cd day1/
$ cargo run
Compiling day1 v0.1.0 (/home/amos/bearcove/aoc2022/day1)
Finished dev [unoptimized + debuginfo] target(s) in 0.31s
Running `target/debug/day1`
Hello, world!
We'll put the problem statement's sample input in there, and later on I'll replace it with my personal input file (that's how AOC works: we all get our own inputs, so to cheat you need at the very least to use someone else's code, you can't just paste their answer).
So, in day1/src/input.txt
, let's paste this:
1000
2000
3000
4000
5000
6000
7000
8000
9000
10000
Cool bear's hot tip
As far as code editors go, if you already have a strong preference, so be it. If you don't, Visual Studio Code is the low-friction option, and it runs great on Windows, macOS and Linux.
Whatever you use, make sure you have rust-analyzer installed and configured correctly.
In particular, you may want to enable as many inlay hints as you're comfortable with: they let you see types, lifetimes, argument names at callsites, etc. They make your code view a little busier, but they're also a great help.
There's a couple ways we can read that file: we can read it at runtime.
fn main() {
let input = std::fs::read_to_string("src/input.txt").unwrap();
println!("{input}");
}
Types and error handling
Why .unwrap()
?
std::fs::read_to_string
returns an std::io::Result<String>
, which is an alias for
std::result::Result<String, std::io::Error>
.
Result<T, E>
is an enum
, also called "sum type": it can be either the Ok(T)
variant (where T
is the type of the value we get if everything went well),
or the Err(E)
variant (where E
is the type of the value we get if something
went wrong).
Here, if std::fs::read_to_string
goes well, we get a String
. In fact, if
you have inlay hints enabled, where you have this code:
let input = std::fs::read_to_string("src/input.txt").unwrap();
You actually see this:
let input: String = std::fs::read_to_string("src/input.txt").unwrap();
// ~~~~~~~~
You can play around with that line of code: if you remove the .unwrap()
,
your file contains this:
let input = std::fs::read_to_string("src/input.txt");
But your code editor shows this:
let input: Result<String, Error> = std::fs::read_to_string("src/input.txt");
// ~~~~~~~~~~~~~~~~~~~~~~~
And that's one of the big things about Rust: our love language is types, and I
think that's beautiful. read_to_string
doesn't "throw an exception", it
returns a value that lets us know if things went fine or not.
And most of the time, in Rust, we're trying to figure out how to go from one type to another type — it can get frustrating, but it's also a wonderful set of guardrails.
We can't accidentally get a String
and "forget" to check the error (a
problem you could have in a language where errors are passed in another channel,
cf. Go's multi-valued returns, or C's "return a non-zero int to signal failure"
pattern).
If we want to ignore the error, we have to be really explicit about it:
let input = match std::fs::read_to_string("src/input.txt") {
Ok(s) => s,
Err(e) => {
// let's ignore the error and return the empty string
"".into()
}
};
Again here, inlay type hints give us more information about what's really happening here:
let input: String = match std::fs::read_to_string("src/input.txt") {
// ~~~~~~~~
Ok(s: String) => s,
// ~~~~~~~~
Err(e: Error) => {
// ~~~~~~~
// let's ignore the error and return the empty string
"".into()
}
};
And in fact, the compiler specifically warns us that we're not doing anything
with the e: Error
we've "destructured" in an "arm" of the "match" expression:
$ cargo check
Checking day1 v0.1.0 (/home/amos/bearcove/aoc2022/day1)
warning: unused variable: `e`
--> src/main.rs:4:13
|
4 | Err(e) => {
| ^ help: if this is intentional, prefix it with an underscore: `_e`
|
= note: `#[warn(unused_variables)]` on by default
warning: `day1` (bin "day1") generated 1 warning
Finished dev [unoptimized + debuginfo] target(s) in 0.19s
...and lets us know 1) which warning it is (unused_variables
, which we can
turn off altogether — probably a bad idea — with the
#[allow(unused_variables)]
attribute) and 2) how to get rid of it, by renaming
e
to _e
for example, or even just _
to throw it away completely.
But if you look at that code, we're already forced to handle both codepaths anyway:
let input = match std::fs::read_to_string("src/input.txt") {
Ok(s) => s,
Err(e) => {
// let's ignore the error and return the empty string
"".into()
}
};
So we might as well handle the error properly anyway. For example, we could print a message (and maybe a backtrace), and safely stop the program's execution:
let input = match std::fs::read_to_string("src/input.txt") {
Ok(s) => s,
Err(e) => panic!("Couldn't read src/input.txt: {e}"),
};
Which would look like this:
$ cargo run
Compiling day1 v0.1.0 (/home/amos/bearcove/aoc2022/day1)
Finished dev [unoptimized + debuginfo] target(s) in 0.31s
Running `target/debug/day1`
thread 'main' panicked at 'Couldn't read src/input.txt: No such file or directory (os error 2)', src/main.rs:4:19
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Or like this, if we set the RUST_BACKTRACE
environment variable to 1
. (Shown
here for zsh/bash, might look different on, say, PowerShell):
$ RUST_BACKTRACE=1 cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.00s
Running `target/debug/day1`
thread 'main' panicked at 'Couldn't read src/input.txt: No such file or directory (os error 2)', src/main.rs:4:19
stack backtrace:
0: rust_begin_unwind
at /rustc/897e37553bba8b42751c67658967889d11ecd120/library/std/src/panicking.rs:584:5
1: core::panicking::panic_fmt
at /rustc/897e37553bba8b42751c67658967889d11ecd120/library/core/src/panicking.rs:142:14
2: day1::main
at ./src/main.rs:4:19
3: core::ops::function::FnOnce::call_once
at /rustc/897e37553bba8b42751c67658967889d11ecd120/library/core/src/ops/function.rs:248:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.
And that's what Result::unwrap does for us.
Instead of writing this:
let input = match std::fs::read_to_string("src/input.txt") {
Ok(s) => s,
Err(e) => panic!("{e}"),
};
We can write this:
let input = std::fs::read_to_string("src/input.txt").unwrap();
And the result is about the same:
$ RUST_BACKTRACE=1 cargo run
Compiling day1 v0.1.0 (/home/amos/bearcove/aoc2022/day1)
Finished dev [unoptimized + debuginfo] target(s) in 0.33s
Running `target/debug/day1`
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "No such file or directory" }', src/main.rs:2:58
stack backtrace:
0: rust_begin_unwind
at /rustc/897e37553bba8b42751c67658967889d11ecd120/library/std/src/panicking.rs:584:5
1: core::panicking::panic_fmt
at /rustc/897e37553bba8b42751c67658967889d11ecd120/library/core/src/panicking.rs:142:14
2: core::result::unwrap_failed
at /rustc/897e37553bba8b42751c67658967889d11ecd120/library/core/src/result.rs:1785:5
3: core::result::Result<T,E>::unwrap
at /rustc/897e37553bba8b42751c67658967889d11ecd120/library/core/src/result.rs:1107:23
4: day1::main
at ./src/main.rs:2:17
5: core::ops::function::FnOnce::call_once
at /rustc/897e37553bba8b42751c67658967889d11ecd120/library/core/src/ops/function.rs:248:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.
Note that our error is lacking context here. We could use Result::expect
instead of Result::unwrap
:
fn main() {
let input = std::fs::read_to_string("src/input.txt").expect("while reading src/input.txt");
println!("{input}");
}
$ cargo run
Compiling day1 v0.1.0 (/home/amos/bearcove/aoc2022/day1)
Finished dev [unoptimized + debuginfo] target(s) in 0.32s
Running `target/debug/day1`
thread 'main' panicked at 'while reading src/input.txt: Os { code: 2, kind: NotFound, message: "No such file or directory" }', src/main.rs:2:58
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
...but that's not great either.
Because maybe we've put all the file reading in its own function:
fn main() {
let input = read_input();
println!("{input}");
}
fn read_input() -> String {
std::fs::read_to_string("src/input.txt").expect("while reading src/input.txt")
}
...and we don't want our read_input
function to panic (via Result::expect
) if
read_to_string
returns a Result::Err(E)
.
Instead, we'd like the read_input
to propagate the error, like so:
fn read_input() -> Result<String, std::io::Error> {
std::fs::read_to_string("src/input.txt")
}
But then we have to handle the error at the callsite, in main
, like so:
fn main() {
let input = read_input().unwrap();
println!("{input}");
}
And we once again lose context:
$ cargo run
Compiling day1 v0.1.0 (/home/amos/bearcove/aoc2022/day1)
Finished dev [unoptimized + debuginfo] target(s) in 0.33s
Running `target/debug/day1`
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "No such file or directory" }', src/main.rs:2:30
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
It doesn't say which file or directory wasn't found. And doing .expect("while reading src/input.txt")
in the main
function feels wrong: it's an implementation detail of the read_input
function, main
shouldn't have to know about it.
So, what can we do?
We can add some context to our error! We can do that by making our own error type:
/// An [std::io::Error] associated with a path
struct PathedIoError {
path: String,
inner: std::io::Error,
}
fn read_input() -> Result<String, PathedIoError> {
let path = "src/input.txt";
match std::fs::read_to_string(path) {
Ok(s) => Ok(s),
Err(e) => Err(PathedIoError {
path: path.into(),
inner: e,
}),
}
}
If we do this, we run into further compiler errors:
$ cargo check
Checking day1 v0.1.0 (/home/amos/bearcove/aoc2022/day1)
error[E0277]: `PathedIoError` doesn't implement `Debug`
--> src/main.rs:2:17
|
2 | let input = read_input().unwrap();
| ^^^^^^^^^^^^ ------ required by a bound introduced by this call
| |
| `PathedIoError` cannot be formatted using `{:?}`
|
= help: the trait `Debug` is not implemented for `PathedIoError`
= note: add `#[derive(Debug)]` to `PathedIoError` or manually `impl Debug for PathedIoError`
note: required by a bound in `Result::<T, E>::unwrap`
--> /home/amos/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/result.rs:1103:12
|
1103 | E: fmt::Debug,
| ^^^^^^^^^^ required by this bound in `Result::<T, E>::unwrap`
help: consider annotating `PathedIoError` with `#[derive(Debug)]`
|
7 | #[derive(Debug)]
|
For more information about this error, try `rustc --explain E0277`.
error: could not compile `day1` due to previous error
Returning an Err(E)
where E
was std::io::Error
worked, because
std::io::Error
implemented all the right "traits". A trait is often just a
list of functions a type can implement, here's the Debug
trait it's
complaining about for example:
// somewhere in the rust standard library
pub trait Debug {
/// Formats the value using the given formatter.
///
/// (cut)
fn fmt(&self, f: &mut Formatter<'_>) -> Result;
}
(Result
here is naked because in the file where the Debug
trait is declared,
it's aliased to std::fmt::Result
, which is an alias for
std::result::Result<(), std::fmt::Error>
. The "happy path" type is ()
, the
empty tuple, which is just nothing, and the "unhappy path" type is a formatting
error).
So, let's summarize what we know:
- types can implement traits
- traits are collections of methods
- the
std::io::Error
type implements thestd::fmt::Debug
trait - our
PathedIoError
type does not - we must fix that
Why must we fix that? Because we're calling Result::unwrap
here:
fn main() {
// 👇
let input = read_input().unwrap();
println!("{input}");
}
And Result::unwrap
has this type signature:
// somewhere in the rust standard library
impl<T, E> Result<T, E> {
pub fn unwrap(self) -> T
where
E: fmt::Debug,
{
// (cut: implementation)
}
}
See that where E: fmt::Debug
? That's a "trait bound".
Result<T, E>
is a generic type: it can exist for any type T
and E
. In
our case, the concrete type read_input
is returning is Result<String, PathedIoError>
: it's right there in our code:
fn read_input() -> Result<String, PathedIoError> {
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
}
So, in Result<T, E>
we have T = String
and E = PathedIoError
, and, for
a laugh, we can do the compiler's job by performing "monomorphization" of the
impl
block we saw above.
Replacing T
and E
with our concrete types, we have:
impl Result<String, PathedIoError> {
pub fn unwrap(self) -> String
where
PathedIoError: fmt::Debug,
{
// (cut: implementation)
}
}
And here, it's a little clearer where the error comes from. We don't yet know
what the Debug
trait is really for, or why Result::unwrap
wants it for its
E
type parameter, but we know we have to do something like:
impl std::fmt::Debug for PathedIoError {
// ???
}
And if we type that in, the compiler complains: "not all trait items
implemented, missing: fmt
".
But rust-analyzer
offers a "quick fix" called "Implementing missing members":
Cool bear's hot tip
You can summon that "Quick Fix" menu by clicking on the light bulb icon, or
pressing Ctrl+.
(Cmd+.
on macOS). If you're not using VS Code, you get to
find out how to invoke it on your own! How fun.
The inline error message you see here is provided by VS Code's Error Lens extension.
And it generates this:
impl std::fmt::Debug for PathedIoError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PathedIoError")
.field("path", &self.path)
.field("inner", &self.inner)
.finish()
}
}
And that's enough to make our program compile and run:
$ cargo run
Compiling day1 v0.1.0 (/home/amos/bearcove/aoc2022/day1)
Finished dev [unoptimized + debuginfo] target(s) in 0.33s
Running `target/debug/day1`
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: PathedIoError { path: "src/input.txt", inner: Os { code: 2, kind: NotFound, message: "No such file or directory" } }', src/main.rs:2:30
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
And here we have the context: we have the path
("src/input.txt") and we have
the inner error, an std::io::Error
with a code
, a kind
, and a message
.
Here's the thing though: the Debug
implementation that rust-analyzer
generated is exactly the same as what would be generated by the Debug
derive
macro.
Oh no, more concepts
Which is to say, we can replace this:
/// An [std::io::Error] associated with a path
struct PathedIoError {
path: String,
inner: std::io::Error,
}
impl std::fmt::Debug for PathedIoError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PathedIoError")
.field("path", &self.path)
.field("inner", &self.inner)
.finish()
}
}
With just this:
/// An [std::io::Error] associated with a path
#[derive(Debug)] // 👈
struct PathedIoError {
path: String,
inner: std::io::Error,
}
...but also, this isn't the friendliest way to format that error.
Maybe we'd better provide our own Debug
implementation, that prints something
more human-readable:
impl std::fmt::Debug for PathedIoError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "for file {:?}: {}", self.path, self.inner)
}
}
// (don't forget to remove `#[derive(Debug)]` on top of `struct PathedIoError`)
$ cargo run
Compiling day1 v0.1.0 (/home/amos/bearcove/aoc2022/day1)
Finished dev [unoptimized + debuginfo] target(s) in 0.30s
Running `target/debug/day1`
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: for file "src/input.txt": No such file or directory (os error 2)', src/main.rs:2:30
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
That is friendlier.
However, I don't really fancy having to write this for every one of my projects.
As it turns out, there's Rust crates (libraries) that do just that: fs-err is one of them, let's add a dependency on it:
$ cargo add fs-err
Updating crates.io index
Adding fs-err v2.9.0 to dependencies.
Features:
- io_safety
- tokio
What this did was look up the latest version of fs-err
on https://crates.io
and add it to our Cargo.toml
manifest:
[package]
name = "day1"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
fs-err = "2.9.0" # 👈
And then our whole program can just be this:
fn main() {
let input = read_input().unwrap();
println!("{input}");
}
fn read_input() -> Result<String, std::io::Error> {
// now from the `fs-err` crate, rather than `std::fs`
fs_err::read_to_string("src/input.txt")
}
$ cargo run
Compiling fs-err v2.9.0
Compiling day1 v0.1.0 (/home/amos/bearcove/aoc2022/day1)
Finished dev [unoptimized + debuginfo] target(s) in 0.48s
Running `target/debug/day1`
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Custom { kind: NotFound, error: Error { kind: OpenFile, source: Os { code: 2, kind: NotFound, message: "No such file or directory" }, path: "src/input.txt" } }', src/main.rs:2:30
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
And we can see that fs-err
is doing essentially the same thing we were doing,
except it's wrapping its Error
type in std::io::Error
.
Here's another way to tackle this: let's remove fs-err
from our Cargo.toml
manifest with cargo rm fs-err
(or by directly editing the file), and add a
dependency on color-eyre instead:
$ cargo add color-eyre
Updating crates.io index
Adding color-eyre v0.6.2 to dependencies.
Features:
+ capture-spantrace
+ color-spantrace
+ tracing-error
+ track-caller
- issue-url
- url
color-eyre
provides us with its own color_eyre::Result<T>
type, which is an
alias for std::result::Result<T, color_eyre::Report>
. Most error types can be
converted to color_eyre::Report
, so, for example, we can do this:
fn read_input() -> color_eyre::Result<String> {
let input = std::fs::read_to_string("src/input.txt")?;
Ok(input)
}
Or this:
fn read_input() -> color_eyre::Result<String> {
std::fs::read_to_string("src/input.txt").map_err(|e| e.into())
// ~~~~~~~~~~~~
// this is a closure 👆
//
// (inlay type hints should show you
// `e: std::io::Error`)
}
Or this:
fn read_input() -> color_eyre::Result<String> {
std::fs::read_to_string("src/input.txt").map_err(From::from)
}
Because the conversion from std::io::Error
to color_eyre::Report
is possible
thanks to this impl
block:
impl<E> From<E> for Report
where
E: StdError + Send + Sync + 'static,
{
#[cfg_attr(track_caller, track_caller)]
fn from(error: E) -> Self {
Report::from_std(error)
}
}
There's a lot more going on here that we're not going to cover, but, just like Debug is a common trait that means a type can be formatted in a somewhat-verbose way, From is a common trait that expresses that "infallible conversions" can occur between two types.
It is closely related to the
Into trait, that
converts in the other direction (and is what lets us do e.into()
)
Me personally, I prefer this form:
fn read_input() -> color_eyre::Result<String> {
let input = std::fs::read_to_string("src/input.txt")?;
Ok(input)
}
Because we often want to do other things with the value, so it's useful to bind
it to a name with let
(here, the binding is named input
).
As-is, our program (when src/input.txt
does not exist) prints this:
$ cargo run --quiet
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: No such file or directory (os error 2)
Location:
src/main.rs:7:17', src/main.rs:2:30
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
So we're back to square one? There's no context here.
That's true, although it's somewhat prettier. And it shows where the error was
actually built (main.rs:7:17
, which is main.rs
line 7 column 17), not just
where it was handled (main.rs:2:30
).
If we install the color-eyre handler, it's prettier still:
fn main() {
// here 👇
color_eyre::install().unwrap();
let input = read_input().unwrap();
println!("{input}");
}
fn read_input() -> color_eyre::Result<String> {
let input = std::fs::read_to_string("src/input.txt")?;
Ok(input)
}
$ cargo run --quiet
The application panicked (crashed).
Message: called `Result::unwrap()` on an `Err` value:
0: No such file or directory (os error 2)
Location:
src/main.rs:9
Backtrace omitted. Run with RUST_BACKTRACE=1 environment variable to display it.
Run with RUST_BACKTRACE=full to include source snippets.
Location: src/main.rs:4
Backtrace omitted. Run with RUST_BACKTRACE=1 environment variable to display it.
Run with RUST_BACKTRACE=full to include source snippets.
Here it is with colors:
Still, we don't have context. So let's add some!
// 👇 this `use` directive was added automatically by rust-analyzer when...
use color_eyre::eyre::Context;
fn main() {
color_eyre::install().unwrap();
let input = read_input().unwrap();
println!("{input}");
}
fn read_input() -> color_eyre::Result<String> {
// ...I did `Ctrl+Space` after typing `.wrap_e` to insert this call 👇
let input = std::fs::read_to_string("src/input.txt").wrap_err("reading src/input.txt")?;
Ok(input)
}
$ cargo run --quiet
The application panicked (crashed).
Message: called `Result::unwrap()` on an `Err` value:
0: reading src/input.txt
1: No such file or directory (os error 2)
Location:
src/main.rs:11
Backtrace omitted. Run with RUST_BACKTRACE=1 environment variable to display it.
Run with RUST_BACKTRACE=full to include source snippets.
Location: src/main.rs:6
Backtrace omitted. Run with RUST_BACKTRACE=1 environment variable to display it.
Run with RUST_BACKTRACE=full to include source snippets.
There! To simplify some more, we can even have main
itself return a Result
,
instead of calling .unwrap()
from there.
Let's try it out:
use color_eyre::eyre::Context;
fn main() -> color_eyre::Result<()> {
// was unwrap 👇
color_eyre::install()?;
// was unwrap 👇
let input = read_input()?;
println!("{input}");
// now we must return a Result
Ok(())
}
fn read_input() -> color_eyre::Result<String> {
let input = std::fs::read_to_string("src/input.txt").wrap_err("reading src/input.txt")?;
Ok(input)
}
$ cargo run --quiet
Error:
0: reading src/input.txt
1: No such file or directory (os error 2)
Location:
src/main.rs:16
Backtrace omitted. Run with RUST_BACKTRACE=1 environment variable to display it.
Run with RUST_BACKTRACE=full to include source snippets.
And that got rid of the duplicate "Backtrace omitted" messages. The only source
location that's shown by default is where the error was constructed, here it's
in read_input
.
And that's all we need to know to solve this advent of code day 1 exercise with Rust.
Wait, what? We haven't solved it at all, not even a little. One might say we got distracted.
No no, on the contrary, we've just gone through a bunch of essential concepts in Rust. Values are of a certain type, types can have methods and they can implement traits. Some types and methods are generic, and can operate on any type that satisfy "trait bounds".
Our job is to turn types into other types, and that'll help us solve the task at hand.
If.. if you say so.
Okay, let's solve the problem then!
Iterators and for loops
First off, let's turn "reading the file" from a run-time problem (when the
program is executed) into a compile-time problem (when the executable is
generated by cargo
/ rustc
/ the linker), using the
include_str
macro:
fn main() -> color_eyre::Result<()> {
color_eyre::install()?;
let input = include_str!("input.txt");
println!("{input}");
Ok(())
}
This does essentially the same thing, except that now, input.txt
doesn't need
to be present when we run the program: it's baked into it.
So we did... all this... for nothing?
Oh no bear, not nothing. We did all this as a gentle... as a rather brutal introduction to types, and traits, and stuff.
See now for example, if we do this:
fn main() -> color_eyre::Result<()> {
color_eyre::install()?;
let input = include_str!("input.txt");
let lines = input.lines();
Ok(())
}
We're not sure what to do with that lines
, right? Well, our code editor tells
us it's of type Lines
:
let lines: Lines = input.lines();
// ~~~~~~~
Which helps because uhh... ok by itself that doesn't help much. But we can open
the context menu on lines
(with right click, most likely) and use the "Go To
Type Definition" command and land in the middle of the standard library:
// in `rustlib/src/rust/library/core/src/str/iter.rs`
/// An iterator over the lines of a string, as string slices.
///
/// This struct is created with the [`lines`] method on [`str`].
/// See its documentation for more.
///
/// [`lines`]: str::lines
#[stable(feature = "rust1", since = "1.0.0")]
#[must_use = "iterators are lazy and do nothing unless consumed"]
#[derive(Clone, Debug)]
pub struct Lines<'a>(pub(super) Map<SplitTerminator<'a, char>, LinesAnyMap>);
#[stable(feature = "rust1", since = "1.0.0")]
impl<'a> Iterator for Lines<'a> {
type Item = &'a str;
#[inline]
fn next(&mut self) -> Option<&'a str> {
self.0.next()
}
// (cut)
}
Ah, it's an iterator: there's a next
method here, we can probably do something
with that:
let mut lines = input.lines();
loop {
let maybe_line = lines.next();
match maybe_line {
Some(line) => {
println!("Got line: {}", line);
}
None => break,
}
}
This is a lot more verbose than it needs to be, but might help draw a closer parallel to some other languages.
Again, inlay type hints give us some info about the next
method:
let maybe_line: Option<&str> = lines.next();
// ~~~~~~~~~~~~~~
Option<T>
is an enum, similar to Result<T, E>
, but simpler. There's Some(T)
(something) and None
(nothing). And we use a match
expression with two arms
to "destructure it", just like we did with results before.
match
is just one of the ways we can do pattern matching though: if-let
is
another one:
fn main() -> color_eyre::Result<()> {
color_eyre::install()?;
let input = include_str!("input.txt");
let mut lines = input.lines();
loop {
if let Some(line) = lines.next() {
println!("Got line: {}", line);
} else {
break;
}
}
Ok(())
}
But wait.. what's this?
$ cargo clippy
warning: this loop could be written as a `while let` loop
--> src/main.rs:7:5
|
7 | / loop {
8 | | if let Some(line) = lines.next() {
9 | | println!("Got line: {}", line);
10 | | } else {
11 | | break;
12 | | }
13 | | }
| |_____^ help: try: `while let Some(line) = lines.next() { .. }`
|
= note: `#[warn(clippy::while_let_loop)]` on by default
= help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#while_let_loop
warning: `day1` (bin "day1") generated 1 warning
Finished dev [unoptimized + debuginfo] target(s) in 0.02s
It's clippy! We love clippy.
Right! And we don't need to link to its website, because it ships by default with rust.
$ rustup which cargo-clippy
/home/amos/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin/cargo-clippy
And it replaces/augments cargo check
, which is what Visual Studio Code's
"rust-analyzer" extension runs by default, but we can change that setting:
{
"rust-analyzer.checkOnSave.command": "clippy",
}
(...which can also be changed in VS Code's graphical settings editor, by searching for "checkOnSave" or something).
Applying the suggestion, our code becomes this:
fn main() -> color_eyre::Result<()> {
color_eyre::install()?;
let input = include_str!("input.txt");
let mut lines = input.lines();
while let Some(line) = lines.next() {
println!("Got line: {}", line);
}
Ok(())
}
And then... another suggestion appears!
$ cargo clippy
warning: this loop could be written as a `for` loop
--> src/main.rs:7:5
|
7 | while let Some(line) = lines.next() {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ help: try: `for line in lines`
|
= note: `#[warn(clippy::while_let_on_iterator)]` on by default
= help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#while_let_on_iterator
warning: `day1` (bin "day1") generated 1 warning
Finished dev [unoptimized + debuginfo] target(s) in 0.01s
Fine, fine. In fact, let's inline everything:
fn main() -> color_eyre::Result<()> {
color_eyre::install()?;
for line in include_str!("input.txt").lines() {
println!("Got line: {line}");
}
Ok(())
}
Wow hey, that almost looks like... idk, Python or something.
Yeah, except for the macros and ?
to propagate errors and the fact that
everything is strongly typed and..
Yes yeah okay, got it.
Splitting, grouping, and other -ings
So anyway, our program prints this:
$ cargo run --quiet
Got line: 1000
Got line: 2000
Got line: 3000
Got line:
Got line: 4000
Got line:
Got line: 5000
Got line: 6000
Got line:
Got line: 7000
Got line: 8000
Got line: 9000
Got line:
Got line: 10000
So, that's cool, it reads each line individually.
Now how about we try to group these, separated by an empty line?
Wait, couldn't we just split on \n\n
?
How do you mean?
Well, our file is something like:
1000\n2000\n3000\n\n4000\n\n5000\n6000...
...right? If we just split on \n\n
we have our groups?
Ah, you're right, let's try it:
for group in include_str!("input.txt").split("\n\n") {
println!("In group:");
for line in group.lines() {
println!(" - {line}");
}
}
$ cargo run --quiet
In group:
- 1000
- 2000
- 3000
In group:
- 4000
In group:
- 5000
- 6000
In group:
- 7000
- 8000
- 9000
In group:
- 10000
Great! So that's it then? We can finish solving the problem?
Not so fast bear! On Windows..
Yes yes, I know, on windows newlines aren't \n
they're \r\n
because
teletypes, blah, who cares, here I'll fix it:
for group in include_str!("input.txt")
.replace("\r\n", "\n")
.split("\n\n")
{
println!("In group:");
for line in group.lines() {
println!(" - {line}");
}
}
Boom, done. Can we move on?
Very well, if you insist — but we'll come back to my way later, right?
Yes, sure, whatever gets us the points for Advent of Code.
...you do realize we're late enough that we'll get 1 point no matter what?
SO THEN WHY ARE WE DO-
...ever hear of this new thing called fun?
Fun! Oh! Fun! Well excuse me, I hadn't noticed we were having fun.
Oh I don't know about you, but me, I've been having tons of fun.
Anyway sure, let's humor you and keep going.
So you want something real imperative huh? No more concepts?
Yes, enough with the concepts and the learning. Let's get straight to the point.
Very well. So, we'll want to parse every non-empty line as a number, right? If we're going to sum them?
Sure, okay, you can have that one.
So we must go from, like... &str
to something like... let's say u64
?
And we can do so like this:
for group in include_str!("input.txt")
.replace("\r\n", "\n")
.split("\n\n")
{
println!("In group:");
for line in group.lines() {
let value = line.parse::<u64>()?;
println!(" - {value}");
}
}
And that gives us:
$ cargo run --quiet
In group:
- 1000
- 2000
- 3000
In group:
- 4000
In group:
- 5000
- 6000
In group:
- 7000
- 8000
- 9000
In group:
- 10000
...that didn't change the output at all.
Yes yes, but now they're numbers. Sum them!
Okay fine, so, imperative style... I guess we make a sum "variable", set it to zero and add values as we go:
for group in include_str!("input.txt")
.replace("\r\n", "\n")
.split("\n\n")
{
let mut sum = 0;
for line in group.lines() {
let value = line.parse::<u64>()?;
sum += value;
}
println!("Group has sum {sum}");
}
$ cargo run --quiet
Group has sum 6000
Group has sum 4000
Group has sum 11000
Group has sum 24000
Group has sum 10000
Yes, yes, good, now find the biggest one.
Well... again imperative style, I guess we can make a largest_sum
variable and
overwrite it if we find a bigger sum...
fn main() -> color_eyre::Result<()> {
color_eyre::install()?;
let mut max = 0;
for group in include_str!("input.txt")
.replace("\r\n", "\n")
.split("\n\n")
{
let mut sum = 0;
for line in group.lines() {
let value = line.parse::<u64>()?;
sum += value;
}
if sum > max {
max = sum;
}
}
println!("The burdenedst elf is carrying {max} calories");
Ok(())
}
Please note that even though every binding here is strongly typed, we don't
have to specify the type of max
, because..
$ cargo run --quiet
The burdenedst elf is carrying 24000 calories
There, we're done, goodbye!
Not so fast bear! First of all, that's not even our real puzzle input. And secondly, you promised we'd give my way a try.
Fine.
Solving it the fun way
So let's go back to this version of the code:
for line in include_str!("input.txt").lines() {
println!("{line}");
}
What I personally would like to do here is to split that iterator into "groups" every time we encounter an empty line.
And this is surprisingly tricky to do!
We can collect all lines into a vector, and then, there's a bunch of interesting
methods on slices,
split
for example:
let lines = include_str!("input.txt").lines().collect::<Vec<_>>();
let groups = lines.split(|line| line.is_empty()).collect::<Vec<_>>();
println!("groups = {groups:?}");
$ cargo run --quiet
groups = [["1000", "2000", "3000"], ["4000"], ["5000", "6000"], ["7000", "8000", "9000"], ["10000"]]
See, this is a perfectly decent start!
let lines = include_str!("input.txt").lines().collect::<Vec<_>>();
let groups = lines.split(|line| line.is_empty()).collect::<Vec<_>>();
let groups = groups
.into_iter()
.map(|group| {
group
.iter()
.map(|v| v.parse::<u64>().ok())
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
println!("groups = {groups:?}");
$ cargo run --quiet
groups = [[Some(1000), Some(2000), Some(3000)], [Some(4000)], [Some(5000), Some(6000)], [Some(7000), Some(8000), Some(9000)], [Some(10000)]]
clippy suggests we don't need that middle collect
, and it is of course,
correct — in fact we can inline the whole thing:
let lines = include_str!("input.txt").lines().collect::<Vec<_>>();
let groups = lines
.split(|line| line.is_empty())
.map(|group| {
group
.iter()
.map(|v| v.parse::<u64>().ok())
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
println!("groups = {groups:?}");
But... it might be easier to do the "parsing as u64" bit before we split:
let lines = include_str!("input.txt")
.lines()
.map(|v| v.parse::<u64>().ok())
.collect::<Vec<_>>();
let groups = lines.split(|line| line.is_some()).collect::<Vec<_>>();
println!("groups = {groups:?}");
$ cargo run --quiet
groups = [[], [], [], [None], [None], [], [None], [], [], [None], []]
Woops, wrong way round, let's try this instead:
// was: `is_some` 👇
let groups = lines.split(|line| line.is_none()).collect::<Vec<_>>();
$ cargo run --quiet
groups = [[Some(1000), Some(2000), Some(3000)], [Some(4000)], [Some(5000), Some(6000)], [Some(7000), Some(8000), Some(9000)], [Some(10000)]]
Now, for each group, we can sum all elements. They're Option<u64>
right now,
we can use Option::unwrap
to turn them into u64
or panic (similar to what
Result::unwrap
does, which we've used a bunch earlier).
Our code becomes:
fn main() -> color_eyre::Result<()> {
color_eyre::install()?;
let lines = include_str!("input.txt")
.lines()
.map(|v| v.parse::<u64>().ok())
.collect::<Vec<_>>();
let groups = lines
.split(|line| line.is_none())
.map(|group| group.iter().map(|v| v.unwrap()).sum::<u64>())
.collect::<Vec<_>>();
println!("groups = {groups:?}");
Ok(())
}
$ cargo run --quiet
groups = [6000, 4000, 11000, 24000, 10000]
And we can find the maximum here without even collecting the groups into their
own Vec<u64>
:
fn main() -> color_eyre::Result<()> {
color_eyre::install()?;
let lines = include_str!("input.txt")
.lines()
.map(|v| v.parse::<u64>().ok())
.collect::<Vec<_>>();
let elven_lead = lines
.split(|line| line.is_none())
.map(|group| group.iter().map(|v| v.unwrap()).sum::<u64>())
// 👇
.max();
println!("{elven_lead:?}");
Ok(())
}
$ cargo run --quiet
Some(24000)
Why does max()
return an Option<T>
you ask?
Nope, nobody asked.
Oh you're still here. Well it's because what's the maximum of an empty iterator?
It's not zero, or i64::min
, or negative infinity: it's... nothing. There's no
maximum then. Hence, None
, or Some(maximum)
.
I'm fairly happy with that solution... but not totally.
I still think that collect
in the middle is unnecessary. I'd rather we do
everything with iterators, so that we could work on very large datasets
without memory usage going up.
Don't we bake the whole input into the executable anyway?
..yes, right, but it's mapped in memory and... because virtual memory... things are paged in and out and... uhh it all works out.
Uh-huh. Eloquent as ever. Continue.
We could, of course, implement our own iterator type:
/// An iterator that takes `Option<u64>` items and yields sums of groups of
/// `Some(u64)` items separated by `None` items.
struct GroupSumIter<I> {
inner: I,
}
impl<I> Iterator for GroupSumIter<I>
where
I: Iterator<Item = Option<u64>>,
{
type Item = u64;
fn next(&mut self) -> Option<Self::Item> {
let mut sum = loop {
match self.inner.next() {
Some(Some(v)) => break v,
Some(None) => {
// huh, weird, didn't expect a separator there
// but let's just skip it
}
// we've reached the end of the inner iterator
None => return None,
}
};
loop {
match self.inner.next() {
Some(Some(v)) => sum += v,
Some(None) | None => {
// reached a separator or the end of the iterator
break Some(sum);
}
}
}
}
}
Which we could use like this:
fn main() -> color_eyre::Result<()> {
color_eyre::install()?;
let lines = include_str!("input.txt")
.lines()
.map(|v| v.parse::<u64>().ok());
// here! 👋
let elven_lead = GroupSumIter { inner: lines }.max();
println!("{elven_lead:?}");
Ok(())
}
And that does work!
$ cargo run --quiet
Some(24000)
I'm afraid we're giving Cool Bear ammunition to nominate this solution as "most convoluted" though.
Yes. Yes indeed.
So let's try and achieve this in less code, shall we?
Whenever we want to do "advanced iterator stuff", the same crate always pops up: itertools.
So, let's try it:
$ cargo add itertools
Updating crates.io index
Adding itertools v0.10.5 to dependencies.
Features:
+ use_alloc
+ use_std
There's two methods I want to try. First, Itertools::batching, which lets us do the same, but without having to declare our own type.
// 👋 this is important, it lets us call `.batching` on iterators
use itertools::Itertools;
fn main() -> color_eyre::Result<()> {
color_eyre::install()?;
let max = include_str!("input.txt")
.lines()
.map(|v| v.parse::<u64>().ok())
.batching(|it| {
let mut sum = None;
while let Some(Some(v)) = it.next() {
sum = Some(sum.unwrap_or(0) + v);
}
sum
})
.max();
println!("{max:?}");
Ok(())
}
This works!
$ cargo run --quiet
Some(24000)
And honestly, I'd be happy with that as my solution.
I'm curious about Itertools::coalesce though, let's give it a shot:
use itertools::Itertools;
fn main() -> color_eyre::Result<()> {
color_eyre::install()?;
let max = include_str!("input.txt")
.lines()
.map(|v| v.parse::<u64>().ok())
.coalesce(|a, b| match (a, b) {
(None, None) => Ok(None),
(None, Some(b)) => Ok(Some(b)),
(Some(a), Some(b)) => Ok(Some(a + b)),
(Some(a), None) => Err((Some(a), None)),
})
.max()
.flatten()
.unwrap_or_default();
println!("{max:?}");
Ok(())
}
$ cargo run --quiet
24000
This is... arguably worse than the the other solution, but it's also hilarious, so it's impossible to tell if it's good or not.
Also, it's easier to follow with inlay hints, as usual:
Anyway, let's try it with the real puzzle input (which is 2248 lines long).
We'll make a release build:
$ cargo build --release
(omitted)
And time it with hyperfine:
$ hyperfine ./target/release/day1
Benchmark 1: ./target/release/day1
Time (mean ± σ): 0.2 ms ± 0.4 ms [User: 0.3 ms, System: 0.1 ms]
Range (min … max): 0.0 ms … 13.9 ms 3081 runs
Warning: Command took less than 5 ms to complete. Note that the results might be inaccurate because hyperfine can not calibrate the shell startup time much more precise than this limit. You can try to use the `-N`/`--shell=none` option to disable the shell completely.
Warning: Statistical outliers were detected. Consider re-running this benchmark on a quiet PC without any interferences from other programs. It might help to use the '--warmup' or '--prepare' options.
Ah, well. Too fast to measure properly 🤷
Part 2
Oh, there's a part a two, forgot about that part. In part two, instead of finding the single buffest elf, we must find the top 3 (and compute the sum of the calories they're carrying).
Well it almost feels like cheating, but once again itertools
has everything we
need.
We can, first of all, flatten our Iterator<Item = Option<u64>>
into a
Iterator<Item = u64>
with Iterator::flatten
. This works because
Option<u64>
itself implements IntoIterator
: it iterates over one item if we
have the Some(T)
variant, or over.. zero items if we have the None
variant.
Then, we can sort it. Naively, it'll sort from smallest to largest. But if we
give it u64::MAX - v
instead of v
, it'll sort from largest to smallest! We
could've also called .rev()
to reverse the iterator after sorting it, but that
sounds more expensive for some reason.
It's low-key expensive in both cases tbh, since it needs to keep track of all elves rather than just the top 3 at any given time.
I mean, expensive is relative since this fits a gazillion times over in main memory, but, y'know.
Then we can take 3 items from the resulting iterator (elves sorted by calories
carried, most to least) with take(3)
, and sum
that!
And there we have it:
use itertools::Itertools;
fn main() -> color_eyre::Result<()> {
color_eyre::install()?;
let answer = include_str!("input.txt")
.lines()
.map(|v| v.parse::<u64>().ok())
.coalesce(|a, b| match (a, b) {
(None, None) => Ok(None),
(None, Some(b)) => Ok(Some(b)),
(Some(a), Some(b)) => Ok(Some(a + b)),
(Some(a), None) => Err((Some(a), None)),
})
.flatten()
.sorted_by_key(|&v| u64::MAX - v)
.take(3)
.sum::<u64>();
println!("{answer:?}");
Ok(())
}
Again, coalesce
here is a bit on the odd side, I still think batching
worked
better, but ah well.
Reader suggestion: std::cmp::Reverse
Instead of doing u64::MAX - v
, we can use Reverse.
This line:
.sorted_by_key(|&v| u64::MAX - v)
becomes:
.sorted_by_key(|&v| std::cmp::Reverse(v))
...which I think expresses intent better! Thanks to freax13
on
Reddit for this suggestion.
Reader suggestion: k_smallest
crazy01010
suggests on Reddit that
we can use itertool's k_smallest function in conjunction with Reverse
to find the "K largest values",
which is what we want.
We can combine this with our batching
solution and a suggestion of my own:
the for
loop we had here:
.batching(|it| {
let mut sum = None;
while let Some(Some(v)) = it.next() {
sum = Some(sum.unwrap_or(0) + v);
}
sum
})
...looks like a potential use case for itertool's fold_while.
The complete solution becomes:
use itertools::{FoldWhile, Itertools};
use std::cmp::Reverse;
fn main() -> color_eyre::Result<()> {
color_eyre::install()?;
let answer = include_str!("input.txt")
.lines()
.map(|v| v.parse::<u64>().ok())
// consider all lines separated by `None`
.batching(|it| {
it.fold_while(None, |acc: Option<u64>, v| match v {
Some(v) => FoldWhile::Continue(Some(acc.unwrap_or_default() + v)),
// that's the group separator, `fold_while` is done
None => FoldWhile::Done(acc),
})
// this returns `Some(total)` if we found a group, `None` if we're
// at the end, to let `batching` end.
.into_inner()
})
// this turns `k_smallest` into `k_largest`
.map(Reverse)
.k_smallest(3)
// and this strips the `Reverse` so we can sum
.map(|x| x.0)
.sum::<u64>();
println!("{answer:?}");
Ok(())
}
And it works beautifully!
Reader suggestions: using a BinaryHeap
Another suggestion by crazy01010
on Reddit: we can use a BinaryHeap to maintain the sums in order: we push the first
three, and any time we push another one, we also pop from the heap.
That way, the heap never grows beyond 4 items, and thanks to
std::cmp::Reverse
, we get the three largest items (instead of the three
smallest, if we used the heap naively).
use itertools::{FoldWhile, Itertools};
use std::{cmp::Reverse, collections::BinaryHeap};
fn main() -> color_eyre::Result<()> {
color_eyre::install()?;
let mut group_sums = include_str!("input.txt")
.lines()
.map(|v| v.parse::<u64>().ok())
.batching(|it| {
it.fold_while(None, |acc: Option<u64>, v| match v {
Some(v) => FoldWhile::Continue(Some(acc.unwrap_or_default() + v)),
None => FoldWhile::Done(acc),
})
.into_inner()
})
.map(Reverse);
let mut heap = BinaryHeap::new();
for init in (&mut group_sums).take(3) {
heap.push(init);
}
for rest in group_sums {
heap.push(rest);
heap.pop();
}
let answer = heap.into_iter().map(|Reverse(v)| v).sum::<u64>();
println!("{answer:?}");
Ok(())
}
I learned something while implementing this solution: I was initially doing
group_sums.take(3)
but it consumed the iterator. Since a mutable reference
also implements Iterator
, we can simply do (&mut group_sums).take(3)
.
Improving the batching
solution
This gave me an idea to improve the batching solution further, with map_while and sum1:
use itertools::Itertools;
use std::cmp::Reverse;
fn main() {
let answer = include_str!("input.txt")
.lines()
.map(|v| v.parse::<u64>().ok())
.batching(|it| it.map_while(|x| x).sum1::<u64>())
.map(Reverse)
.k_smallest(3)
.map(|x| x.0)
.sum::<u64>();
println!("{answer:?}");
}
Look how pretty!
(Thanks to Hodkinson on GitHub for pointing out this version of the code doesn't need "mut sprinkling". It doesn't!)
Here's another article just for you:
Aiming for correctness with types
The Nature weekly journal of science was first published in 1869. And after one and a half century, it has finally completed one cycle of carcinization, by publishing an article about the Rust programming language.
It's a really good article.
What I liked about this article is that it didn't just talk about performance, or even just memory safety - it also talked about correctness.