Tags: rant, programming
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.
days, and everybody1 loves to pretend that a shiny
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 # 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!
1 2 3 4 5 6 7 8 9 10 11 # 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. $(PROGS): $(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:
1 2 3 4 5 6 7 8 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!
1 2 3 4 .PRECIOUS: hello.s hello: hello.c $(CC) -S $< $@.s $(CC) hello.s -o $@
Better yet, make the implicit explicit:
1 2 3 hello hello.s: hello.c $(CC) -S $< $@.s $(CC) hello.s -o $@
I want my builds to run in parallel!
1 make -j$(nproc)
1 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:
1 2 3 .PHONY: frobulate frobulate: curl https://example.com/
.PHONY is good practice for targets that don’t produce corresponding outputs.
Here are some phony targets that I frequently stuff into my Makefiles:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 .PHONY: fmt fmt: # or black, rubocop -a, ... clang-format -i -style=file $(SRCS) .PHONY: lint lint: # or flake8, black, clang-tidy, ... bundle exec rubocop lib/ test/ .PHONY: doc 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.
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.