i like single-file
builds for small projects
I’m not a big fan of C and C++ build systems. For anything that’s not
really really large, I just put everything into one file. This is
usually called a “unity build”. For most of my own projects a unity
build is all I need, so I haven’t had to mess with build systems in
quite a long time. I think many people waste time figuring out more
complicated systems when a unity build will work great and let them
focus on more important things. A unity build is a bit weird in some
ways, and the issues it solves are relatively small, but on balance for
small projects I like doing things this way.
basic unity builds in c
A “standard” C build involves compiling your .c files
into .o files, and then linking them together into the
final library or executable. Each of these C files is compiled as its
own module. Declarations are used to make a module aware of functions
that are implemented in other modules. Usually we group up declarations
into .h files and #include them
everywhere.
The way I do unity builds flips this on its head. I still split my
program into separate C files, but I compile them all together in one
unit. I’ll usually have a main.c file and then I’ll
#include the other .c files directly into it. If
pizza.c depends on functionality in cheese.c,
I just put cheese.c first in the list of
#includes. If there’s a circularity, I’ll add some forward
declarations as needed.
My build command becomes one single compile step:
cc -o foo foo.c. All of the other .c files are
pulled in through the includes. I’ll throw that into a Justfile with all the warning
command line args I like, and use pkg-config for any dependencies on the
system.
a neat trick for test
runners
I’ll add multiple executables to my build as a project grows. There
are a couple of neat ways to add test runners to this setup.
In a test runner file you can #include all the same
.c files, and either run tests defined alongside the code
in each C file or define your tests separately in the test runner. You
can also add a #ifndef TEST around main and
then include main.c directly in your test file. The test
file defines its own main, and also defines
TEST to turn off the definition implemented in
main.c. This saves you having to list out all of the C
files in two places.
There is one downside to the unity build setup that I don’t love.
Typically in C I like to use static extensively to hide
implementation details within each C file. Each C file gets some private
global state in static vars and has a small public
interface with the rest of its functions declared static.
That information hiding breaks down once you include everything into a
single C file. In C++ projects anonymous namespaces break too. The unity
build breaks intuition about things that are “file scoped”.
Another downside is that LSP servers are usually baffled by unity
builds. Add it to the list of reasons I still prefer vim + ctags.
an example from the newdle
solver
I’ve been working on a solver for newdle,
which uses a unity build with a test runner.
/* newdle.c */
// I keep a prelude.h with all my usual stdlib includes, assert macro,
// typedefs for shorter / clearer type names, common utility functions,
// stuff I tend to reuse everywhere.
#include "prelude.h"
// Single-header utilities get included next, usually with a macro
// to define implementations here too. These can drop into a more
// traditional build nicely.
#define ARENA_IMPL
#include "arena.h"
#define ARGS_IMPL
#include "args.h"
// I would include .c files here, but the newdle solver hasn't grown
// large enough to bother splitting yet.
... most of the newdle code ...
#ifndef TEST
i32 main(i32 argc, str *argv)
{
}
#endif
/* tests.c */
// The macro shuts off newdle.c's main function and includes everything.
#define TEST
#include "newdle.c"
// ...and then the main here runs all the tests.
# justfile
# Build just builds both exes (my actual file does have tons of command
# line args for sanitizers, warnings set up just the way I like, etc.)
build:
clang -Wall -o newdle newdle.c
clang -Wall -o newdle_test tests.c
I’ve been trying out Zig lately, and I like how this approach works
there too. The auto-generated build.zig template felt pretty verbose due
to comments and trying to demonstrate things. It’s not too bad once you
break it down, but I appreciate that I can use build-exe to
do a simple unity build style there too.
I’m most comfortable starting a project with one file and a command
to run it, instead of having to run a template generator to make a whole
directory structure just to get started. Then once the program has grown
a bit and it’s clear where the seams are between modules, you can easily
rip stuff out and reimport it into the main file. For me it’s a very
natural and lightweight way to work. Running a generator always feels
heavy to me, even if the stuff it’s generating isn’t actually that big
or complicated.