The case against Makefiles
You don’t need a Makefile. Whatever your use-case, there is a better tool.
Makefiles are archaic technology, living on by force of inertia and notalgia. The syntax is opaque and the error messages are unhelpful. It’s time to move on.
As far as I can see, there are two remaining use cases for Makefiles in the wild:
building C and C++ code;
running arbitrary tasks that have dependencies between each other.
Makefiles are no longer the best solution for either of these use cases,
Use-case 1: Building C and C++ code
Makefiles are the original build system. The make program was born in 1976 at Bell Labs, out of a frustration with existing build scripts (piles of sh). The concepts it pionneered (_e.g._ the build graph) are still the basis of modern build systems, but the ergonomics of build systems have improved a lot since then.
Aside: Build scripts, makefiles, and other build systems
Consider a script that builds a pile of source files (without using any fancy shell features that would have been unavailable in 1976)
#!/bin/sh
CFLAGS="-Wall"
# Compile the individual source files to object files
cc $CFLAGS -c -o main.o main.c
cc $CFLAGS -c -o lib.o lib.c
# Link the object files into an executable
cc -o app main.o lib.o
Forget to add a cc command to build a source file? Now you’re linking with an old version of the file that you compiled by hand.
Using modern bash features, you could improve the script significantly.
#!/bin/bash
CFLAGS="-Wall"
SRCS = "main.c lib.c"
OBJS = ${SRCS/.c/.o}
for file in $SRCS; do
cc $CFLAGS -c -o ${file/.c/.o} $file
done
cc -o app $OBJS
Using a bash variable expansion to replace .c with .o allows us to use re-use the list of sources in both the compile commands and the linker command. For a small example program, this approach works, but it doesn’t scale well.
An equivalent makefile would be
CFLAGS = -Wall
SRCS = main.c lib.c
OBJS = $(SRCS:.c=.o)
app: $(OBJS)
$(CC) -o $@ $(OBJS)
%.o: %.c
$(CC) $(CFLAGS) -c -o $@ $<
The similarities are quite obvious. The most significant difference between the script and the makfile is what we describe. The script is an imperative procedure: it lays out the steps that need to be accomplished to build the final binary, and executes them all in sequence, every time. The makefile describes the dependencies between inputs and outputs. This allows the makefile to skip steps that are redundant because the input has not changed.
The cost of this change is syntax. The build script is quite readable but the makefile is full of _magic_ tokens. What are $@, and $<? To an experienced practitioner, they are obvious. For me, they require a trip to google each time (or if I actually remember, a trip into The Sum of All Knowledge, my knowledgebase in`Obsidian`_).
Compare this with an equivalent CMake script
cmake_minimum_required(VERSION 3.10)
project(app C)
add_executable(app main.c lib.c)
Without knowing anything about CMake, the goal of the script is quite clear. The knowledge of how to actually build an executable or a library is hidden which is a shame, but the intent is clear.
Make’s mistake
The fundamental mistake of Make is that it describes the edges of the build graph, not the nodes. It describes the operations required to transform one file into another, but never describes the goal of the transformation.
%.o: %.c
$(CC) $(CFLAGS) -c -o $@ $<
To build a file, we say that there exists a node %.c and a node %.o, and that to go from %.c to %.o, it is necessary to apply $(CC) $(CFLAGS) -c -o $@ $< to %.c. A no point does this say anything about what we are trying to achieve.
In CMake, to build an executable, we use add_executable(app main.c lib.c). This describes the end goal, not the transformation. This is not to say that CMake is the perfect build-system, far from that, but it is at least easier to follow.
Build-systems that describe intent, and not transformations, have one property that most Makefiles in the wild lack: they work reliably when run in parallel. Most makefiles I have had the misfortune of encountering fail when run in parallel. To build reliably, build scripts become
make -j$(nproc) all || make -j$(nproc) all || make -j$(nproc) all
This is definitely a skill issue. But a tool too complicated for the average C programmer inside a boring company is no longer a good tool.
Re-inventing the wheel with make
Make aficionados love to tell new victims how great their makefiles are: they are invariably better than whatever sub-par build system they have begrudgingly accepted to use at their workplace.
However, if my experience is representative, their makefiles still suffer from the edges-not-nodes issue discussed above, but more problematically: they have accumulated just as much complexity as other build systems, whilst having written none of the documentation that would be required to explain their bespoke build system to others.
Need to add a new library target? Need to add a custom target for code-generation, doxygen, a formatter, a linter, … ? jump on a call and ask them to walk you though it.
And after all that, you still end up rm -rf build/ at every other build because you don’t trust the damn thing …
And because it doesn’t actually work when run in parallel, the compile times are agonising …
Use case 2: Running tasks
The concept of a dependency graph generalises well to many other problems, not just compiling code.
Using a makefile to express that the publish-docker rule depends on the build-docker rule seems natural. Sure you have to mark the rule .PHONY to tell make that the outputs are not actual files … but it works.
.PHONY: publish-docker
publish-docker: build-docker
docker image push my-image:latest
.PHONY: build-docker
build-docker:
docker image build -t my-image docker/my-image
Sure, it took you a couple of tries because you forgot to change your editor settings to use tabs for indentation instead of spaces, but it works.
But now you have a second image you want to build. You copy the rule, but feel icky. The whole point of using a makefile was to avoid this kind of duplication.
.PHONY: publish-docker-foo
publish-docker-foo: build-docker-foo
docker image push my-foo-image:latest
.PHONY: build-docker-foo
build-docker-foo:
docker image build -t my-foo-image docker/my-foo-image
.PHONY: publish-docker-bar
publish-docker-bar: build-docker-bar
docker image push my-bar-image:latest
.PHONY: build-docker-bar
build-docker-bar:
docker image build -t my-bar-image docker/my-bar-image
Instead of copying the rule, you’d like to re-use and somehow pass the image name as a parameter … and now you’re stuck. A make wizard would spit out a magic command that generates the rules with more make magic but how maintainable is that?
You’ve hit the limits of make. It isn’t made for this. You need an actual task runner. Something that doesn;t think in files, but in actual tasks, that might have arguments. There are many out there, but my personal go-to is just. The equivalent justfile is
build-docker-foo: (build-docker "my-foo-image")
publish-docker-foo: (publish-docker "my-foo-image")
build-docker-bar: (build-docker "my-bar-image")
publish-docker-bar: (publish-docker "my-bar-image")
publish-docker {{ IMAGE_NAME }}:
docker image push {{ IMAGE_NAME }}
build-docker IMAGE_NAME:
docker image build -t {{ IMAGE_NAME }} docker/{{ IMAGE_NAME }}
Isn’t that better? Out-of-the-box we also get a list of rules (and a documentation comment for each one) with —list, the ability to group them in the output, the ability to import other justfiles without recursive makefile hacks, the ability to execute the body of a rule as a script (no need to add a \ at the end of each line like in a makefile) …
It’s just a better tool. There are plenty of others out there that might fit your aesthetic better, just don’t use make for this.