Ruby/Crystal/Rust Advent of Code 2022 - Day 1

Ruby/Crystal/Rust Advent of Code 2022 - Day 1

Welcome to my Advent of Code adventure for 2022! This year, I am going to solve the Advent of Code challenges using three different languages - Ruby, Crystal, and Rust. The goal is not to golf for the most terse answer in each language, but rather to implement reasonably idiomatic, but conceptually similar solutions in each language, and to use that as a tool to compare and contrast the languages. So, without further adieu, let's get started!

The Repository

All of the code for this day's solutions, as well as the data input file, can be found at:

https://github.com/wyhaines/advent-of-code-2022/tree/main/day_1

Part 1

To see the full details of the challenge, visit the Advent of Code 2022 - Day 1 page. However, the tl;dr version of the challenge is as follows:

In this puzzle, a group of Elves is going on an expedition to collect star fruit for Santa's reindeer. Each Elf is carrying food items with different numbers of calories. The puzzle is to determine which Elf is carrying the most calories and how many total calories they are carrying. The calories of each food item are listed in a sequence of numbers, with a blank line separating the inventory of each Elf. To solve the puzzle, the numbers must be read and the total calories for each Elf must be calculated. The Elf with the highest total calories is the answer to the puzzle.

So, for example, given the following input:

1000
2000
3000

4000

5000
6000

7000
8000
9000

10000

The fourth elf would be carrying 7000 + 8000 + 9000 == 24000 calories and that would be the answer to this puzzle.

The Approach

The approach to solving this puzzle involves the following steps:

  1. read the input

  2. split it into chunks delimited by repeated newlines

  3. map each chunk into an array of integers

  4. sum the individual arrays of integers

  5. find the largest sum

This is a pretty straightforward approach, and it can be implemented in Ruby, Crystal, and Rust in similar ways.

Ruby solution

The Ruby solution that I wrote looks like this:

the_most_calories = File.read('input.txt')
                        .split(/\n\n/)
                        .map do |group|
  group
    .strip
    .split(/\n/)
    .map(&:to_i)
end
                        .map(&:sum)
                        .max

puts the_most_calories

The solution breaks down into the following steps.

the_most_calories = File.read('input.txt')
                        .split(/\n\n/)

This solution assumes that the input is in a file called input.txt.

That file is read into memory and then split into chunks delimited by repeated newlines (i.e. a blank line).

.map do |group|
  group
    .strip
    .split(/\n/)
    .map(&:to_i)
end

The resulting array is then mapped, splitting each chunk into an array of text lines, which are then converted, via map(&:to_i), into an array of integers. At this point, the program holds an array of arrays of integers.

                        .map(&:sum)
                        .max

The next step is to sum each of the inner arrays, and then to find the maximum of those sums. This is done via the map(&:sum).max method calls.

Crystal solution

For this first task, the Crystal solution is nearly identical to the Ruby solution:

the_most_calories = File.read("input.txt")
  .split(/\n\n/)
  .map do |group|
    group
      .strip
      .split(/\n/)
      .map(&.to_i)
  end
  .map(&.sum)
  .max

puts the_most_calories

The only difference is that the &. is used instead of the &: syntax for the map() inline method calls and double quotes are used around the filename instead of single quotes.

Rust solution

The Rust solution is conceptually identical to the Ruby and Crystal solutions, but the syntax is a bit different, and there is some additional explicit type annotation that is required.

use std::fs;

fn main() {
    let the_most_calories: i32 = fs::read_to_string("input.txt")
        .unwrap()
        .split("\n\n")
        .map(|group| {
            group
                .trim()
                .split("\n")
                .map(|line| line.parse::<i32>().unwrap())
        })
        .map(|numbers| numbers.sum())
        .max()
        .unwrap();

    println!("{}", the_most_calories);
}

The most interesting aspect, for me, of solving this with Rust was just how similar the Rust solution ended up being to the Ruby and the Crystal solutions. Rust is more verbose, it requires more ceremony, and its compiler's type inference requires more explicit type annotations than Crystal requires, but the overall structure of the solution still ends up being very similar to the other two, down to the names of the methods that are used in the solution.

Because a Rust program requires a main() function to execute anything, the program structure can't be quite so simple as the Ruby and Crystal structures, but for this first task, I just put all of the logic directly into main().

let the_most_calories: i32 = fs::read_to_string("input.txt")
        .unwrap()
        .split("\n\n")

As with the previous implementations, the first thing to be done is to read the input file, and to split it into chunks of lines, delimited by a double newline. The unwrap() call is used because fs::read_to_string() returns a Result, which unwrap() handles, returning either the data contained in the Result, or a panic (an error) of the result is None.

.map(|group| {
            group
                .trim()
                .split("\n")
                .map(|line| line.parse::<i32>().unwrap())
        })

The next step, as with the previous examples, is to split each of the chunks of lines into individual lines, and convert them to integers. The syntax is a little more involved, however. The first significant difference is that trim() call. With Ruby and with Crystal, whitespace is ignored when text is being cast to an integer via to_i(). With Rust, however, if unexpected whitespace is encountered, you can get an error:

thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: ParseIntError { kind: Empty }', ./day_1_2.rs:11:49
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Using the trim() method ensures that the input is clean. Following that, it is split just like with the other languages, and then, like the other languages, a map() call is made. The specific syntax to transform text to an integer is different, but the end result is the same. Once that map() runs, the array of text chunks has become an array of arrays of integers.

        .map(|numbers| numbers.sum())
        .max()
        .unwrap();

That final map() sums the array of integers, replacing it with the result of that call, and the max() method call that follows finds the maximum value in the array.

That's pretty straightforward. There are some small syntax differences, but ultimately the Rust implementation doesn't stray too far from the Ruby and the Crystal versions.

Part 2

The gist of the second part of the puzzle is as follows:

The Elves are interested in finding the sum of the Calories carried by the top three Elves carrying the most Calories to avoid running out of snacks. In the example data given, the top three Elves are the fourth Elf (with 24000 Calories), then the third Elf (with 11000 Calories), then the fifth Elf (with 10000 Calories). The sum of the Calories carried by these three elves is 45000.

The Approach

The approach to solving this puzzle is nearly identical to the approach used to solve part 1. The steps are as follows:

  1. read the input

  2. split it into chunks delimited by repeated newlines

  3. map each chunk into an array of integers

  4. sum the individual arrays of integers

  5. sort the list of sums

  6. take the last three elements of the list

Here is how to make those changes to the Ruby, Crystal, and Rust solutions.

Ruby solution

the_most_calories = File.read('input.txt')
                        .split(/\n\n/)
                        .map do |group|
  group
    .strip
    .split(/\n/)
    .map(&:to_i)
end
                        .map(&:sum)
                        .sort
                        .last(3)

puts the_most_calories.inspect
puts the_most_calories.sum

The only difference is at the end of the chain of methods. The sort method is used to sort the list of sums, and then the last(3) method is used to select the last three elements of the list.

Crystal solution

the_most_calories = File.read("input.txt")
  .split(/\n\n/)
  .map do |group|
    group
      .strip
      .split(/\n/)
      .map(&.to_i)
  end
  .map(&.sum)
  .sort
  .last(3)

puts the_most_calories.inspect
puts the_most_calories.sum

Again, the only difference is that the sort method is used to sort the list of sums, and then the last(3) method is used to select the last three elements of the list.

Rust solution

use std::fs;

fn main() {
    let mut the_most_calories: Vec<i32> = fs::read_to_string("input.txt")
        .unwrap()
        .split("\n\n")
        .map(|group| {
            group
                .trim()
                .split("\n")
                .map(|line| line.parse::<i32>().unwrap())
                .sum()
        })
        .collect::<Vec<_>>();
    the_most_calories.sort_by(|a, b| b.cmp(a));
    the_most_calories.truncate(3);

    println!(
        "{:?}\n{}",
        the_most_calories,
        the_most_calories.iter().sum::<i32>()
    );
}

The implementation for Rust again follows closely the implementations for Ruby and for Crystal, with one notable difference. In the Crystal and the Ruby versions, it was possible to chain all of the method calls back to back, because Ruby and Crystal both have sort methods that return the sorted array. With Rust, however, the various sorting methods such as sort() and sort_by() act on their argument directly, sorting the elements in place, and returning () instead of the array. The truncate() method does the same. Thus, in the Rust example, we break the call chain and use separate statements to sort and to truncate.

    the_most_calories.sort_by(|a, b| b.cmp(a));
    the_most_calories.truncate(3);

Conclusion

This was a fun puzzle, and it served as a good introduction to the general ways in which conceptually similar code can vary between the chosen languages, particularly between Rust and the other two languages. The key differences with the Rust version were the need to explicitly annotate the types of variables, and the need to explicitly unwrap the Result values returned by several of the methods, but the solutions were otherwise quite similar across all three languages.