The last couple of days I took a break from this chronicle of my attempt to teach myself the Rust programming language by solving the programming puzzles on Advent of Code 2020. But now I’m back with another two days’ worth of puzzles!

One thing that I read in the meantime, thanks to a tip from Federico was the first installment of someone else’s blog who’s doing the same thing as I am. That blog is a really good read, and I think the main difference with my series is that the author is already a Rust expert! The style is also very different, as well; I am mostly trying to emphasize the things that I found surprising, struggled with, or didn’t understand. Their blog is much more didactic.^{1}

One really cool thing that I picked up from that blog post is the `include_str!()`

macro, which makes it possible to read the `input`

file into a string at compile time, and dispense with the `read_lines()`

boilerplate and most of the error handling. I think I will be using this from now on. This way also makes it easy to substitute in the example inputs from the puzzle descriptions.

Also in the meantime, I spent a lot of time trying to configure either of the Rust Enhanced or SublimeLinter-contrib-rustc plugins for my Sublime Text editor. I had a surprisingly hard time with this, neither of them seemed to work. In the latter case it was because the plugin was out of sync with the API of the newest version of SublimeLinter. I thought the former plugin, Rust Enhanced, would be a smooth experience since it claims to be the officially sanctioned Rust plugin for Sublime Text, but it didn’t work either! Whenever I would try to run any of the Rust commands, I would get this message:

Blocking waiting for file lock on build directory

I finally figured out that this error goes away if you run `cargo clean`

. My guess, it’s probably because I copied the `puzzle15`

folder to `puzzle16`

today, instead of creating it with `cargo new`

! My bad!

The editor plugin makes development much smoother, since I can get the compiler’s answer directly when I save the file, without even leaving my editor. This makes the endless rounds of compile, add a `&`

, compile, add a `*`

, go much faster.

It is mildly annoying that the plugin shows the compiler messages inline, instead of in the editor gutter like all the SublimeLinter plugins do. One day when I have time, I’ll investigate to see if there is a setting that controls this.

On to the puzzle.

## Day 16, Part 1

This puzzle is about train tickets. We get a more free-form `input`

file than usual, and we have to use it to deduce the meanings of fields on train tickets that we’ve scanned. In the `input`

file there is a list of valid ranges for each field, and lists of fields on train tickets. Some of the train tickets are invalid, and that’s what Part 1 of the puzzle is about. The answer is the sum of all the values on any ticket that are not valid no matter what field they belong to.

I start by thinking about what data structure I would use to store the ranges. Some of them are overlapping and it would be nice to be able to collapse those. I google “rust collapse range” and land in the `intervallum`

package, where the `IntervalSet`

type seems like exactly what I want for this. It can collapse two intervals into a single interval set which covers the two intervals, and it is allowed to have one or more holes in the middle. So I can collapse all the valid ranges together, and then check each ticket field value to see if it is contained within the any of the valid ranges.

Here’s the program that I write. I split the input into three blocks, and process the first one (the one with the valid ranges in it.)

extern crate gcollections; extern crate interval; #[macro_use] extern crate scan_fmt; use gcollections::ops::set::Union; use interval::interval_set::ToIntervalSet; fn main() { let input = "class: 1-3 or 5-7\n\ row: 6-11 or 33-44\n\ seat: 13-40 or 45-50\n\ \n\ your ticket:\n\ 7,1,14\n\ \n\ nearby tickets:\n\ 7,3,47\n\ 40,4,50\n\ 55,2,20\n\ 38,6,12\n"; // let input = include_str!("input"); let mut blocks = input.split("\n\n"); let mut constraints = vec![].to_interval_set(); blocks .next() .unwrap() .lines() .map(|line| { let mut parts = line.split(": "); (parts.next().unwrap(), parts.next().unwrap()) }) .flat_map(|(_, intervals)| intervals.split(" or ")) .map(|interval| { scan_fmt!(interval, "{d}-{d}", u16, u16) .unwrap() .to_interval_set() }) .for_each(|interval| constraints = constraints.union(&interval)); println!("{}", constraints); let my_ticket_block = blocks.next().unwrap(); assert!(my_ticket_block.starts_with("your ticket:\n")); let other_tickets_block = blocks.next().unwrap(); assert!(other_tickets_block.starts_with("nearby tickets:\n")); }

I initially thought `.collect::<Vec<(u16, u16)>>().to_interval_set()`

would work, but that gives me a panic: “This operation is only for pushing interval to the back of the array, possibly overlapping with the last element.” so apparently we have to use `union()`

on each item. Having a bit of experience with Rust and its reputation for safety, I’d expect it to return a `Result`

or something if you tried this, instead of crashing. This seems consistent with my impression that some Rust packages are high quality and others are a bit rough around the edges, similar to NPM packages. This one seems a bit rough and underdocumented, but good enough that we can still use it.

To use the `union()`

method, I inexplicably have to include the `gcollections`

package as well.

Once that’s done I can process the third block as well, and calculate the answer with one long iterator chain:

let other_tickets_block = blocks.next().unwrap(); assert!(other_tickets_block.starts_with("nearby tickets:\n")); let error_rate: u16 = other_tickets_block .lines() .skip(1) .flat_map(|line| line.split(',')) .map(|s| s.parse().unwrap()) .filter(|val| !constraints.contains(val)) .sum(); println!("Error rate: {}", error_rate);

## Day 16, Part 2

In Part 2 of the puzzle we have to discard all the tickets that had an invalid value, and with the remaining tickets figure out which field belongs to which field name, by comparing the fields with the valid ranges for each field name. The answer is the product of all the values on our own ticket that correspond to fields whose name starts with “departure”.

A lot of the logic will be the same as in Part 1, so I start by rewriting Part 1 to save the intermediate steps that we’ll need for Part 2 as well:

let mut blocks = input.split("\n\n"); let constraints_block = blocks.next().unwrap(); let field_descriptions: HashMap<&str, interval::interval_set::IntervalSet<u16>> = constraints_block .lines() .map(|line| { let mut parts = line.split(": "); let field_name = parts.next().unwrap(); let intervals = parts.next().unwrap(); let interval_set = intervals .split(" or ") .map(|interval| scan_fmt!(interval, "{d}-{d}", u16, u16).unwrap()) .collect::<Vec<(u16, u16)>>() .to_interval_set(); (field_name, interval_set) }) .collect(); let mut all_valid_values = vec![].to_interval_set(); for interval in field_descriptions.values() { all_valid_values = all_valid_values.union(interval); } let my_ticket_block = blocks.next().unwrap(); assert!(my_ticket_block.starts_with("your ticket:\n")); let other_tickets_block = blocks.next().unwrap(); assert!(other_tickets_block.starts_with("nearby tickets:\n")); let (valid_tickets, invalid_tickets): (Vec<Vec<u16>>, Vec<Vec<u16>>) = other_tickets_block .lines() .skip(1) .map(|line| { line.split(',') .map(|s| s.parse().unwrap()) .collect::<Vec<u16>>() }) .partition(|ticket| ticket.iter().all(|val| all_valid_values.contains(val))); if is_part2() { } else { let error_rate: u16 = invalid_tickets .iter() .flat_map(|ticket| ticket.iter().filter(|val| !all_valid_values.contains(val))) .sum(); println!("Error rate: {}", error_rate); }

I am excited to use the `partition()`

method, that I found by browsing the documentation, to split the iterator into two vectors, one of valid and one of invalid tickets.

I now have vectors of the numbers from each ticket. However, to solve this puzzle I’ll have to transform those into vectors of all the numbers in each position on the tickets (for example, a vector of all numbers coming first on each ticket, coming second, etc.) instead of vectors of each ticket. This sounds like something I should be able to do with a `zip()`

method which I’m familiar with from Python.

Rust’s `zip()`

takes only two iterators. At first I think that `itertools::multizip`

will solve my problem, but that takes a number of iterators that must be known at compile time, which is not the case here. After some more googling I find this Stack Overflow answer and find a code snippet which I simply copy as-is into my program. I don’t really understand it, but I understand enough to see approximately what it does.

I decide to store the possible fields for each position on the ticket as a vector of hashsets, `possible_fields_by_position`

. The order of the vector is by position of the number on the ticket. The elements of the vector are hash sets consisting of zero or more field numbers, which the numbers in this position might be valid for.

To do this, I have to pre-fill the vector with empty hashsets, and I’m not sure how to do this. Googling “rust fill vector of hashset” sends me here.

If the problem is solvable, then there will be at least one element of the vector where the hash set has only one item in it, and then we will know what field corresponds with that position on the ticket. We can then consider that field “determined” and remove it from all the other hash sets in the vector, which hopefully leaves at least one more hash set with only one item, allowing is to uniquely determine another field, and so on.

One interesting thing to note is that I first had this:

for (_, remaining_fields) in possible_fields_by_position { remaining_fields.remove(field_ix); }

Here, for the first time that I’ve seen, the compiler gives a plain wrong suggestion:

error[E0382]: borrow of moved value: `possible_fields_by_position` --> puzzle16.rs:97:15 | 78 | let mut possible_fields_by_position: Vec<(usize, HashSet<usize>)> = (0..valid_tickets[0] | ------------------------------- move occurs because `possible_fields_by_position` has type `Vec<(usize, std::collections::HashSet<usize>)>`, which does not implement the `Copy` trait ... 97 | while possible_fields_by_position.len() > 0 { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ value borrowed here after move ... 102 | for (_, remaining_fields) in possible_fields_by_position { | --------------------------- | | | value moved here, in previous iteration of loop | help: consider borrowing to avoid moving into the for loop: `&possible_fields_by_position` error[E0596]: cannot borrow `remaining_fields` as mutable, as it is not declared as mutable --> puzzle16.rs:103:17 | 102 | for (_, remaining_fields) in possible_fields_by_position { | ---------------- help: consider changing this to be mutable: `mut remaining_fields` 103 | remaining_fields.remove(field_ix); | ^^^^^^^^^^^^^^^^ cannot borrow as mutable

Even without the above, I certainly had more trouble than usual with getting the `&`

operators right! Maybe it’s time for a re-read of the chapter on ownership in the Rust book.

For the last step I am also left wondering how to pre-fill a vector with zeros. The syntax is `[0; number_of_zeros]`

. I do actually stumble upon a Github issue complaining about it not being documented well enough.

Here is the very long full program:

extern crate gcollections; extern crate interval; #[macro_use] extern crate scan_fmt; use gcollections::ops::set::{Contains, Union}; use interval::interval_set::{IntervalSet, ToIntervalSet}; use std::collections::HashSet; use std::env; // https://stackoverflow.com/a/55292215/172999 struct Multizip<T>(Vec<T>); impl<T> Iterator for Multizip<T> where T: Iterator, { type Item = Vec<T::Item>; fn next(&mut self) -> Option<Self::Item> { self.0.iter_mut().map(Iterator::next).collect() } } fn main() { let input = include_str!("input"); let mut blocks = input.split("\n\n"); let constraints_block = blocks.next().unwrap(); let field_descriptions: Vec<(&str, IntervalSet<u16>)> = constraints_block .lines() .map(|line| { let mut parts = line.split(": "); let field_name = parts.next().unwrap(); let interval_set = parts .next() .unwrap() .split(" or ") .map(|interval| scan_fmt!(interval, "{d}-{d}", u16, u16).unwrap()) .collect::<Vec<(u16, u16)>>() .to_interval_set(); (field_name, interval_set) }) .collect(); let mut all_valid_values = vec![].to_interval_set(); for (_, interval) in &field_descriptions { all_valid_values = all_valid_values.union(interval); } let my_ticket_block = blocks.next().unwrap(); assert!(my_ticket_block.starts_with("your ticket:\n")); let other_tickets_block = blocks.next().unwrap(); assert!(other_tickets_block.starts_with("nearby tickets:\n")); let (valid_tickets, invalid_tickets): (Vec<Vec<u16>>, Vec<Vec<u16>>) = other_tickets_block .lines() .skip(1) .map(read_csv_numbers) .partition(|ticket| ticket.iter().all(|val| all_valid_values.contains(val))); if is_part2() { let mut possible_fields_by_position: Vec<_> = (0..valid_tickets[0].len()) .map(|_| HashSet::new()) .enumerate() .collect(); for (position, position_values) in Multizip(valid_tickets.iter().map(|ticket| ticket.iter()).collect()).enumerate() { for (field_ix, (_, interval)) in field_descriptions.iter().enumerate() { if position_values.iter().all(|val| interval.contains(val)) { possible_fields_by_position[position].1.insert(field_ix); } } } possible_fields_by_position.sort_by_key(|(_, set)| set.len()); possible_fields_by_position.reverse(); let mut determined_fields_by_position = vec![0; possible_fields_by_position.len()]; while !possible_fields_by_position.is_empty() { let (position, possible_fields) = possible_fields_by_position.pop().unwrap(); assert!(possible_fields.len() == 1, "unable to determine fields"); let field_ix = possible_fields.iter().next().unwrap(); determined_fields_by_position[position] = *field_ix; for (_, remaining_fields) in &mut possible_fields_by_position { remaining_fields.remove(field_ix); } } let my_ticket_values: Vec<u16> = my_ticket_block .lines() .skip(1) .flat_map(read_csv_numbers) .collect(); let answer: u64 = determined_fields_by_position .iter() .map(|field_ix| field_descriptions[*field_ix].0) .zip(my_ticket_values.iter()) .filter(|(field_name, _)| field_name.starts_with("departure")) .map(|(_, value)| *value as u64) .product(); println!("My ticket values {:?}", answer); } else { let error_rate: u16 = invalid_tickets .iter() .flat_map(|ticket| ticket.iter().filter(|val| !all_valid_values.contains(val))) .sum(); println!("Error rate: {}", error_rate); } } fn read_csv_numbers(line: &str) -> Vec<u16> { line.split(',').map(|s| s.parse().unwrap()).collect() }

## Day 17, Part 1

I didn’t manage to finish the Day 16 blog post before I started working on Day 17’s puzzle, so I’m tacking it on here. Day 17 brings us a three-dimensional Conway’s Game of Life!

I remember very well implementing Game of Life with a different twist on Day 11, so today I will write some more code using `ndarray`

, copying from Day 11 where I can.

In three dimensions we have 3^{3} − 1 = 26 neighbours instead of the 3^{2} − 1 = 8 that we have in two dimensions. I “hand-unrolled”^{2} the loop over the 8 neighbours in the `calc_neighbours()`

function from Day 11:

fn calc_neighbours(seats: &Array2<i8>) -> Array2<i8> { let shape = seats.shape(); let width = shape[0]; let height = shape[1]; let mut neighbours = Array2::<i8>::zeros(seats.raw_dim()); // Add slices of the occupied seats shifted one space in each direction let mut slice = neighbours.slice_mut(s![1.., 1..]); slice += &seats.slice(s![..width - 1, ..height - 1]); slice = neighbours.slice_mut(s![.., 1..]); slice += &seats.slice(s![.., ..height - 1]); slice = neighbours.slice_mut(s![..width - 1, 1..]); slice += &seats.slice(s![1.., ..height - 1]); slice = neighbours.slice_mut(s![1.., ..]); slice += &seats.slice(s![..width - 1, ..]); slice = neighbours.slice_mut(s![..width - 1, ..]); slice += &seats.slice(s![1.., ..]); slice = neighbours.slice_mut(s![1.., ..height - 1]); slice += &seats.slice(s![..width - 1, 1..]); slice = neighbours.slice_mut(s![.., ..height - 1]); slice += &seats.slice(s![.., 1..]); slice = neighbours.slice_mut(s![..width - 1, ..height - 1]); slice += &seats.slice(s![1.., 1..]); neighbours }

That will simply not do when we have 26 neighbours! I decide to first rewrite it in the Day 11 code with the outer product of two vectors `[-1, 0, 1]`

and make sure that it still gives the same answer. I find that the `cartesian_product()`

method from Itertools is ideal for this:

fn calc_neighbours(seats: &Array2<i8>) -> Array2<i8> { let shape = seats.shape(); let width = shape[0] as isize; let height = shape[1] as isize; let mut neighbours = Array2::<i8>::zeros(seats.raw_dim()); // Add slices of the occupied seats shifted one space in each direction for (xstart, ystart) in (-1..=1).cartesian_product(-1..=1) { if xstart == 0 && ystart == 0 { continue; } let xdest = xstart.max(0)..(width + xstart).min(width); let ydest = ystart.max(0)..(height + ystart).min(height); let xsource = (-xstart).max(0)..(width - xstart).min(width); let ysource = (-ystart).max(0)..(height - ystart).min(height); let mut slice = neighbours.slice_mut(s![xdest, ydest]); slice += &seats.slice(s![xsource, ysource]); } neighbours }

It’s still pretty verbose, and I suspect it could be done more cleverly, but this is good enough to be straightforwardly adapted to three dimensions for Day 17:

fn calc_neighbours(grid: &Array3<i8>) -> Array3<i8> { let shape = grid.shape(); let width = shape[0] as isize; let height = shape[1] as isize; let depth = shape[2] as isize; let mut neighbours = Array3::<i8>::zeros(grid.raw_dim()); // Add slices of the occupied grid shifted one space in each direction for starts in iter::repeat(-1..=1).take(3).multi_cartesian_product() { if starts.iter().all(|start| *start == 0) { continue; } let (xstart, ystart, zstart) = (starts[0], starts[1], starts[2]); let xdest = xstart.max(0)..(width + xstart).min(width); let ydest = ystart.max(0)..(height + ystart).min(height); let zdest = zstart.max(0)..(depth + zstart).min(depth); let xsource = (-xstart).max(0)..(width - xstart).min(width); let ysource = (-ystart).max(0)..(height - ystart).min(height); let zsource = (-zstart).max(0)..(depth - zstart).min(depth); let mut slice = neighbours.slice_mut(s![xdest, ydest, zdest]); slice += &grid.slice(s![xsource, ysource, zsource]); } neighbours }

Unlike on Day 11, the game board is infinite and has no walls. However, also unlike on Day 11 where we had a termination condition for the game, today we have to simulate exactly 6 iterations of the game, so we can actually pretend the board is finite. The occupied cells can expand at by 1 every turn at most, so the board can be bounded at the size of the input board plus the number of iterations in every direction.

In the example given in the puzzle description, the board is 3×3×1 and we simulate 3 iterations, so the maximum board size would be 9×9×7 in the example. (The example’s actual output fits in 7×7×5, but we need an upper bound.)

I’m able to reuse a *lot* of the code from Day 11, so this should look very familiar if you’ve been following along:

use itertools::Itertools; use ndarray::{s, Array3}; use std::iter; fn main() { // let input = ".#.\n..#\n###\n"; let input = include_str!("input"); let n_turns = 6; let mut grid = read_grid(input, n_turns); for _ in 0..n_turns { let neighbours = calc_neighbours(&grid); let activations = &neighbours.mapv(|count| (count == 3) as i16) * &grid.mapv(|active| (active == 0) as i16); let deactivations = &neighbours.mapv(|count| (count < 2 || count > 3) as i16) * &grid; grid = grid + activations - deactivations; } dump_grid(&grid); println!("{}", grid.sum()); } fn calc_neighbours(grid: &Array3<i16>) -> Array3<i16> { let shape = grid.shape(); let width = shape[0] as isize; let height = shape[1] as isize; let depth = shape[2] as isize; let mut neighbours = Array3::<i16>::zeros(grid.raw_dim()); // Add slices of the occupied grid shifted one space in each direction for starts in iter::repeat(-1..=1).take(3).multi_cartesian_product() { if starts.iter().all(|start| *start == 0) { continue; } let (xstart, ystart, zstart) = (starts[0], starts[1], starts[2]); let xdest = xstart.max(0)..(width + xstart).min(width); let ydest = ystart.max(0)..(height + ystart).min(height); let zdest = zstart.max(0)..(depth + zstart).min(depth); let xsource = (-xstart).max(0)..(width - xstart).min(width); let ysource = (-ystart).max(0)..(height - ystart).min(height); let zsource = (-zstart).max(0)..(depth - zstart).min(depth); let mut slice = neighbours.slice_mut(s![xdest, ydest, zdest]); slice += &grid.slice(s![xsource, ysource, zsource]); } neighbours } fn read_grid(input: &str, padding: usize) -> Array3<i16> { let lines: Vec<&str> = input.lines().collect(); let height = lines.len(); let width = lines[0].len(); let mut cells = Array3::zeros((width + 2 * padding, height + 2 * padding, 2 * padding + 1)); for (y, line) in lines.iter().enumerate() { for (x, tile) in line.bytes().enumerate() { cells[[x + padding, y + padding, padding]] = match tile { b'#' => 1, b'.' => 0, _ => panic!("Bad tile '{}'", tile), }; } } cells } fn dump_grid(grid: &Array3<i16>) { for xy in grid.axis_iter(ndarray::Axis(2)) { for x in xy.axis_iter(ndarray::Axis(1)) { println!( "{}", x.mapv(|active| if active != 0 { '#' } else { '.' }) .iter() .collect::<String>() ) } println!(""); } }

The main difference is that I’ve written a `dump_grid()`

function as well.

I remarked on Day 11 that in my opinion the ndarray package suffers from a few deficiencies compared to NumPy, and I ran into those same problems today:

- The return value of
`sum()`

is limited to the data type of the array it is being called on. - You can’t easily use boolean arrays as a mask.

## Day 17, Part 2

Part 2 of the puzzle is exactly the same as Part 1, only four-dimensional! I briefly wish that I had spent the time on generalizing `calc_neighbours()`

to work with any number of dimensions. But on the other hand, I really don’t feel confident enough with either Rust or ndarray that I could anticipate being able to do that without spending all day on it.

So instead I decide to do the simplest thing: copy the file to another `puzzle17-2.rs`

and add another section to `Cargo.toml`

:

[[bin]] name = "puzzle17-2" path = "puzzle17-2.rs"

I notice that you now need to specify which binary to run, which is nice, because I wasn’t sure how I would choose between the two:

error: `cargo run` could not determine which binary to run. Use the `--bin` option to specify a binary, or the `default-run` manifest key. available binaries: puzzle17, puzzle17-2

Then I just change all the `Array3`

to `Array4`

and add an extra index where needed:

use itertools::Itertools; use ndarray::{s, Array4}; use std::iter; fn main() { let input = include_str!("input"); let n_turns = 6; let mut grid = read_grid(input, n_turns); for _ in 0..n_turns { let neighbours = calc_neighbours(&grid); let activations = &neighbours.mapv(|count| (count == 3) as i16) * &grid.mapv(|active| (active == 0) as i16); let deactivations = &neighbours.mapv(|count| (count < 2 || count > 3) as i16) * &grid; grid = grid + activations - deactivations; } println!("{}", grid.sum()); } fn calc_neighbours(grid: &Array4<i16>) -> Array4<i16> { let shape = grid.shape(); let width = shape[0] as isize; let height = shape[1] as isize; let depth = shape[2] as isize; let limit4 = shape[3] as isize; let mut neighbours = Array4::<i16>::zeros(grid.raw_dim()); // Add slices of the occupied grid shifted one space in each direction for starts in iter::repeat(-1..=1).take(4).multi_cartesian_product() { if starts.iter().all(|start| *start == 0) { continue; } let (xstart, ystart, zstart, wstart) = (starts[0], starts[1], starts[2], starts[3]); let xdest = xstart.max(0)..(width + xstart).min(width); let ydest = ystart.max(0)..(height + ystart).min(height); let zdest = zstart.max(0)..(depth + zstart).min(depth); let wdest = wstart.max(0)..(limit4 + wstart).min(limit4); let xsource = (-xstart).max(0)..(width - xstart).min(width); let ysource = (-ystart).max(0)..(height - ystart).min(height); let zsource = (-zstart).max(0)..(depth - zstart).min(depth); let wsource = (-wstart).max(0)..(limit4 - wstart).min(limit4); let mut slice = neighbours.slice_mut(s![xdest, ydest, zdest, wdest]); slice += &grid.slice(s![xsource, ysource, zsource, wsource]); } neighbours } fn read_grid(input: &str, padding: usize) -> Array4<i16> { let lines: Vec<&str> = input.lines().collect(); let height = lines.len(); let width = lines[0].len(); let mut cells = Array4::zeros(( width + 2 * padding, height + 2 * padding, 2 * padding + 1, 2 * padding + 1, )); for (y, line) in lines.iter().enumerate() { for (x, tile) in line.bytes().enumerate() { cells[[x + padding, y + padding, padding, padding]] = match tile { b'#' => 1, b'.' => 0, _ => panic!("Bad tile '{}'", tile), }; } } cells }

This gives me the right answer.

## Afterword

The puzzles seem to be getting harder as we go along, particularly Day 16 more than Day 17. I am almost certain that Day 16 could be solved much more elegantly than the solution that I had. Day 17, on the other hand, gave me a chance to improve on the solution that I already had for Day 11.

Having the compiler messages directly in my editor is a mixed blessing! It’s definitely streamlined things, but on the other hand I think it has actually made me more lazy and less inclined to learn about when the `&`

operator is necessary — I’ve caught myself just adding them here and there in likely places without bothering to think, and letting the compiler sort the rest out. I’m definitely not this careless with pointers in C!

[1] And therefore probably more useful if you actually want to learn Rust yourself ↩

[2] Or more like, the loop was never rolled in the first place because it was too complicated for me to write at the time ↩