Site logo

Advent of Code in C++

Intro

Very late to the party on this one, seeing as it’s already the 19th, but better late than never, as they say. After doing the lot in Go last year (coincidentally the first year I actually completed the whole thing), I’ve decided to give it a go in C++ this time around, if only to prove a point about it not being as difficult to set up or poorly suited to this sort of thing as some people say. Advent of Code puzzles - the early days, at least - are roughly 50% parsing the input, 50% actually solving the puzzle. I’m not going to claim that C++ is a good choice for string manipulation, or that the iostream library is intuitive and simple to use, but I don’t think it’s as bad as its reputation once you take the time to understand it a little.

For reference, I’m going to be using Fedora 35, which means GCC 11, so language support is considered complete up to C++17. Of course, I’ll be using Meson to tie everything together, and Git to keep track of it all.

Let’s get started with a basic input parsing shell, then tackle day 1.

Bootstrapping

First, a bit of groundwork: let’s get a basic build system going, and a Hello World up and running, just to verify that I can indeed do what I want to do. I’m going to be using a very simplistic folder layout:

aoc-2021
  |- meson.build
  |- src
  |   |- meson.build
  |   |- day01.cxx
  |   |- day02.cxx
  |   |- [and so forth...]
  |- data
  |   |- day01
  |   |- day02
  |   |- [and so forth...]
  \- build
      \- src
          |- day01
          |- day02
          |- [and so forth...]

So just a basic top-level build file, with a singular subdirectory containing a top-level file for each day; then Meson will be configured to output everything to the build subdirectory, giving us a binary per day under build/src. Puzzle inputs will be stored in text files under data/. The top-level meson.build and everything under src/ & data/ will be committed to Git; build, as it is generated output, will just remain local.

The top-level meson.build:

project('aoc-2021', 'cpp', license: 'GPL3+',
    default_options: ['cpp_std=c++17'])
subdir('src')

src/meson.build:

day01 = executable('day01', 'day01.cxx')

src/day01.cxx:

#include <iostream>

int main(int argc, char const * const argv[])
{
    std::cout << "Hello, world!\n";
    return 0;
}

Tying it all together:

phil@hue:aoc-2021$ meson setup build --buildtype=debug
The Meson build system
Version: 0.59.4
Source dir: /home/phil/Projects/aoc-2021
Build dir: /home/phil/Projects/aoc-2021/build
Build type: native build
Project name: aoc-2021
Project version: undefined
C++ compiler for the host machine: ccache c++ (gcc 11.2.1 "c++ (GCC) 11.2.1 20211203 (Red Hat 11.2.1-7)")
C++ linker for the host machine: c++ ld.bfd 2.37-10
Host machine cpu family: x86_64
Host machine cpu: x86_64
Build targets in project: 1

Found ninja-1.10.2 at /usr/bin/ninja
phil@hue:aoc-2021$ cd build/
phil@hue:build$ ninja
[2/2] Linking target src/day01
phil@hue:build$ ./src/day01
Hello, world!

So far, so good.

Input parsing

As puzzle inputs are going to be dumped into files under data/, let’s create a basic shell that takes a filename on the command-line, opens the file, and loops over it line-by-line, giving us a string containing each line’s contents. I don’t want to litter the whole thing with error-checking, but I also don’t want random transient I/O failures to silently break things, so I’m going to turn on exceptions in the file stream, but leave them uncaught (the “crash early, crash often” school of error handling).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <fstream>
#include <iostream>
#include <string>

int main(int argc, char const * const argv[])
{
    // Check we have actually been supplied with a filename
    if (argc != 2)
    {
        std::cerr << "Usage: " << argv[0] << " <filename>\n";
        return 1;
    }

    // Open the file, throwing exception on failure
    std::ifstream fstr;
    fstr.exceptions(std::ios_base::badbit | std::ios_base::failbit);
    fstr.open(argv[1]);

    // Read the file line-by-line - this reads & discards newlines.
    // NB: If the last byte of the file is a newline (std::getline's default
    // delimiter), it will read & discard it without setting eofbit, and the
    // next call to getline will set failbit (and, given the exception mask
    // above, throw an exception). So check we aren't at or immediately
    // adjacent to EOF.
    for (std::string line; (!fstr.eof())
            && (fstr.peek() != std::ifstream::traits_type::eof())
            && std::getline(fstr, line); )
    {
        std::cout << line << '\n';
    }

    return 0;
}

The additional EOF checking on lines 25..27 is a bit messy, but IMHO preferable to not throwing exceptions under any unexpected errors - recoverable or otherwise - when we want to keep things simple and just get on with the business of actually handling the input. Error handling is definitely one of the more confusing aspects of iostreams, and getting used to knowing when something will or won’t set failbit (recoverable error), badbit (unrecoverable error), or eofbit (end of file) - and the ways to check these conditions - is a bit of an artform. The iostate page on cppreference.com helps, in particular the truth table near the bottom. Cppreference.com is generally excellent, but is very much a reference, not a tutorial or guide. But if you have an idea where to look for a particular piece of functionality, and can grok the language-lawyer content, it’s indispensable.

This code behaves as I want/expect: when not passed a filename, it prints a usage message and exits; given a non-existent/unreadable file, it crashes with an uncaught exception; given files that do and don’t end in a newline character (examples of the latter easily generated by, say, echo -n foo > foofile), it reads & prints out each line of the file, exiting cleanly at EOF.

Day 1

Of course, to actually solve part 1 of the day one puzzle, we need to convert each line of the input to a number. There are numerous ways we could do this: falling back to C++’s C roots and using atoi, using std::stoi from C++11, etc. In keeping with the themes of using streams, and failing early & often, let’s create an istringstream to parse each individual line, with its own exception mask set to throw on failure.

The cppreference.com page on basic_istringstream helpfully notes that “resetting” a string stream with str() may be faster than constructing streams in a tight loop. We aren’t really too concerned with performance here (not to this sort of degree, anyway), but constructing a stream & setting its exception mask once, outside the loop, also helps keep the amount of boilerplate inside the loop to a minimum.

#include <fstream>
#include <iostream>
#include <sstream>
#include <string>

int main(int argc, char const * const argv[])
{
    if (argc != 2)
    {
        std::cerr << "Usage: " << argv[0] << " <filename>\n";
        return 1;
    }

    std::ifstream fstr;
    fstr.exceptions(std::ios_base::badbit | std::ios_base::failbit);
    fstr.open(argv[1]);

    std::istringstream istr;
    istr.exceptions(std::ios_base::badbit | std::ios_base::failbit);

    // First line doesn't count - previous line is "N/A". Fake this by
    // simply starting one below zero, and always treating the first
    // line of input as an increase.
    int prev = 0;
    int numIncreases = -1;

    for (std::string line; (!fstr.eof())
            && (fstr.peek() != std::ifstream::traits_type::eof())
            && std::getline(fstr, line); )
    {
        istr.clear();
        istr.str(line);

        int current;
        istr >> current;

        if (current > prev)
            ++numIncreases;

        prev = current;
    }

    std::cout << numIncreases << '\n';
    return 0;
}

Is this more verbose than it could be in other languages? Absolutely. Will it, by design, crash on non-existent files, or malformed input (i.e. one of the lines can’t be converted to an int), without being littered with error checking? Yes. Is it, in my opinion, as bad as some people would assume? IMHO, no. More importantly: as the input gets more complex, if I screw up the input parsing, chances are it’ll be in a way that causes either std::getline() or std::operator>> to crash; at which point I’ll simply load it up in GDB and get to work (or just start peppering it with debug prints).

Will I be re-using this basic boilerplate across all the puzzles? Probably.