Programming, philosophy, pedaling.

Make is (probably) fine

Apr 23, 2019

Tags: rant, programming

A necessary disclaimer

This blog post is not completely serious. It is not a call to delete your current build system and replace it with a set of Makefiles. It is not a call to learn by suffering through obscure syntax, just because that's the way the wizards do it. It is merely a reminder that the vast majority of builds are simple things, and simple things demand no more than simple, default tools.

If you're starting a new project (or rewriting an old one), you might be tempted to try out a cool new build system. There are a lot of them these days, and everybody1 loves to pretend that a shiny new toy tool will fix (or at least mask) the cultural/engineering problems that made the last tool "the wrong fit."

To that I say: Don't. (GNU) Make is probably fine.

GNU Make isn't Make! You're cheating!

Oh well. GNU Make is (or is installable) everywhere that matters. Most of the examples below will work with non-GNU Make with little to no changes. But I'll leave that up to you.

But Make sucks! It's hard to read and write.

I agree: the syntax is terrible. But don't worry: it's probably fine. The best Make builds are the simplest ones, anyways.

Here's how you build a set of source files into a program:

# Collect all of our source files.
SRCS = $(wildcard *.c)

# Map each source file to its object file.
OBJS = $(SRCS:.c=.o)

# The name of our program is "foo".
PROG = foo

# The default `make` target is always the first in the file.
# Since the next target in this file is just the program itself,
# we could actually remove this line without changing what
# `make` does if we call it with no arguments.
all: $(PROG)

# foo depends on every object file.
$(PROG): $(OBJS)

Make is a deceptively smart program: it already knows how to make source files into object files, and it can see (via the default target) that it can satisfy our dependency graph by combining those object files into a single program. It wants to do the right thing; all we have to do is not interfere with it too much.

But I need to build multiple programs!

No worries:

# The names of our programs.
PROGS = foo bar baz

# Like above, we could remove this line without changing the behavior
# of a bare `make` invocation. It's just good style to keep it.
all: $(PROGS)

# Run `make` inside of each program's subdirectory (e.g. src/foo/).
# Each subdirectory should contain a file like the first example.
	$(MAKE) -C src/$<

You are structuring your source code into individual directories for libraries and programs, right?

If not, this works perfectly well when each program corresponds to a single source/object file:

SRCS = $(wildcard *.c)
OBJS = $(SRCS:.c=.o)

# Map each object file to a program name (e.g. foo.o -> foo).
PROGS = $(basename $(OBJS))

# Necessary here: it's our only target!
all: $(PROGS)

But I want to keep intermediate and/or implicit outputs around!

.PRECIOUS: hello.s
hello: hello.c
	$(CC) -S $< $@.s
	$(CC) hello.s -o $@

Better yet, make the implicit explicit:

hello hello.s: hello.c
	$(CC) -S $< $@.s
	$(CC) hello.s -o $@

I want my builds to run in parallel!


make -j$(nproc)


make -j$(sysctl -n hw.physicalcpu)

(You can also pass -j without a number to let Make use as many jobs as it desires.)

Running Make in parallel does not make it dumb: it's still deceptively smart under the hood. If you avoid fighting the default rules and behavior, your Make builds will parallelize painlessly.

Make uses timestamps, and timestamps are a terrible way to track dependencies!

This is true. But Linux is built with Make. GCC and LLVM are built with Make (the latter via CMake). Like so many other things in the UNIX world, timestamps were the wrong choice but work shockingly well for the vast majority of cases.

My build includes tasks that don't map to Make's notion of targets!

As everybody knows from the horrible Makefiles that their colleagues write, Make is perfectly fine as a task runner:

.PHONY: frobulate
	curl https://example.com/

That .PHONY is good practice for targets that don't produce corresponding outputs.

Here are some phony targets that I frequently stuff into my Makefiles:

.PHONY: fmt
	# or black, rubocop -a, ...
	clang-format -i -style=file $(SRCS)

.PHONY: lint
	# or flake8, black, clang-tidy, ...
	bundle exec rubocop lib/ test/

.PHONY: doc
	# or doxygen, sphinx, ...
	bundle exec yardoc
	bundle exec yardoc stats --list-undoc

My team wants to use $HIP_NEW_BUILD_TOOL! $BIG_COMPANY spends a lot of time and money making sure it's the best.

$BIG_COMPANY spends a lot of time and money making sure $HIP_NEW_BUILD_TOOL is the best for their purposes, not yours. Make is probably fine for you.

If you do decide to use $HIP_NEW_BUILD_TOOL, make sure that it supports the platforms you want to build on, including obscure ones like Windows.2

In a similar vein:

All of your examples are for C/C++, and we use $OTHER_LANGUAGE!

If $OTHER_LANGUAGE has a tightly integrated build system, then you should use it. Make is not a good choice for those languages, with small exceptions for use as a dumb task runner on the periphery.

Otherwise, Make is probably fine. Most compiled languages have build procedures that are amenable to Make's target model. Even contemporary JavaScript is a decent enough fit, given the prevalence of transpilers and weird, slightly different dialects.

I've gone through this whole post, and I still have $GOOD_REASON for not using Make!

$GOOD_REASON probably is a good reason. My favorite reason for not using Make is the absence of automatic cleanup targets. Writing clean targets manually is an error-prone pain, especially if your build includes code generation, IDE artifacts, kernel modules, &c.

Try CMake! It's probably fine, especially CMake 3.2+. I'm personally guilty of using it with Ninja to speed up builds.

  1. The author does not exclude himself. 

  2. The author is aware of the hypocrisy involved in complaining about Buck's Windows support while recommending Make.