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).
|
|
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.