Contact
Evil Genius SocietyHomeLearnForumLeaderboardSocialsMessages
← Back to module

Lesson 7 of 10

Every command in this module so far has been typed by hand. For three files that is fine. For three hundred it is not, and the reason is not typing — it is that you do not want to rebuild what has not changed.

The problem a build system solves

A shell script that compiles everything is correct and slow. A shell script that compiles only what changed is fast and, written by hand, wrong.

Make exists to answer one question per file: is this output older than its inputs?

// include/geometry.hpp
#pragma once

double circle_area(double r);
// src/circle.cpp
#include "geometry.hpp"

double circle_area(double r) { return 3.14159265358979 * r * r; }
// src/main.cpp
#include <iostream>
#include "geometry.hpp"

int main() { std::cout << circle_area(2.0) << "\n"; }
# Makefile
CXX      := g++
CXXFLAGS := -std=c++20 -Wall -Wextra -Iinclude
SRCS     := src/circle.cpp src/main.cpp
OBJS     := $(SRCS:.cpp=.o)

app: $(OBJS)
	$(CXX) $(OBJS) -o $@

%.o: %.cpp
	$(CXX) $(CXXFLAGS) -c $< -o $@

clean:
	rm -f $(OBJS) app

Four ideas, and that is most of Make:

  • A rule is target: prerequisites, then tab-indented commands. The indentation must be a real tab.
  • %.o: %.cpp is a pattern rule: it explains how to make any .o from the matching .cpp.
  • $@ is the target, $< is the first prerequisite.
  • := assigns immediately; $(SRCS:.cpp=.o) rewrites each suffix.
make
echo "=== make again, nothing changed:"
make
echo "=== touch src/circle.cpp:"
touch src/circle.cpp && make
g++ -std=c++20 -Wall -Wextra -Iinclude -c src/circle.cpp -o src/circle.o
g++ -std=c++20 -Wall -Wextra -Iinclude -c src/main.cpp -o src/main.o
g++ src/circle.o src/main.o -o app
=== make again, nothing changed:
make: 'app' is up to date.
=== touch src/circle.cpp:
g++ -std=c++20 -Wall -Wextra -Iinclude -c src/circle.cpp -o src/circle.o
g++ src/circle.o src/main.o -o app

That third command recompiled circle.cpp and relinked, and left main.o alone. Exactly right.

The bug

touch include/geometry.hpp && make
make: 'app' is up to date.

The header changed and nothing rebuilt. Look at the Makefile again: %.o: %.cpp says an object file depends on its source. Nothing in it mentions headers, so as far as Make is concerned, geometry.hpp is not part of the build.

For a declaration-only change you might get away with it. Here is what it costs when you do not:

// include/box.hpp
#pragma once

struct Box { int w; int h; };

int area(const Box& b);
// src/vol.cpp
#include "box.hpp"

int area(const Box& b) { return b.w * b.h; }
// src/main.cpp
#include <iostream>
#include "box.hpp"

int main() {
    Box b{};
    b.w = 3;
    b.h = 7;
    std::cout << "sizeof(Box)=" << sizeof(Box) << "  area=" << area(b) << "  (expected 21)\n";
}
# Makefile
CXX      := g++
CXXFLAGS := -std=c++20 -Wall -Wextra -Iinclude
SRCS     := src/vol.cpp src/main.cpp
OBJS     := $(SRCS:.cpp=.o)

app: $(OBJS)
	$(CXX) $(OBJS) -o $@

%.o: %.cpp
	$(CXX) $(CXXFLAGS) -c $< -o $@
make -s && ./app
sizeof(Box)=8  area=21  (expected 21)

Now insert a field in the middle of Box and touch only main.cpp — the sort of thing that happens constantly when two people edit a project:

// include/box.hpp
#pragma once

struct Box { int w; int depth; int h; };

int area(const Box& b);
touch src/main.cpp && make -s && ./app
echo "=== after a full rebuild:"
rm -f src/*.o app && make -s && ./app
sizeof(Box)=12  area=0  (expected 21)
=== after a full rebuild:
sizeof(Box)=12  area=21  (expected 21)

area=0. main.o was rebuilt and knows Box has three fields; vol.o is stale and still believes h lives at offset 4, where depth now sits. It multiplied 3 by 0.

This is lesson 17.2's ODR violation again, and note how it got here: nobody wrote inconsistent code. The source is consistent. The build produced two object files compiled against different versions of one header, which is the same thing as far as the program is concerned.

A full rebuild gives 21. That is why "try make clean" is folk wisdom — and why relying on it is not a build system.

The fix: let the compiler report its own dependencies

The compiler knows exactly which headers it opened. -MMD makes it write them out as a Makefile fragment:

# Makefile
CXX      := g++
CXXFLAGS := -std=c++20 -Wall -Wextra -Iinclude -MMD -MP
SRCS     := src/vol.cpp src/main.cpp
OBJS     := $(SRCS:.cpp=.o)
DEPS     := $(OBJS:.o=.d)

app: $(OBJS)
	$(CXX) $(OBJS) -o $@

%.o: %.cpp
	$(CXX) $(CXXFLAGS) -c $< -o $@

clean:
	rm -f $(OBJS) $(DEPS) app

-include $(DEPS)
rm -f src/*.o app
make -s && ./app
echo "=== what -MMD wrote into src/vol.d:"
cat src/vol.d
echo "=== now touch the header:"
touch include/box.hpp && make && ./app
sizeof(Box)=12  area=21  (expected 21)
=== what -MMD wrote into src/vol.d:
src/vol.o: src/vol.cpp include/box.hpp
include/box.hpp:
=== now touch the header:
g++ -std=c++20 -Wall -Wextra -Iinclude -MMD -MP -c src/vol.cpp -o src/vol.o
g++ -std=c++20 -Wall -Wextra -Iinclude -MMD -MP -c src/main.cpp -o src/main.o
g++ src/vol.o src/main.o -o app
sizeof(Box)=12  area=21  (expected 21)

Both objects rebuilt, and the answer is right.

The pieces:

  • -MMD writes src/vol.d alongside the object, containing the real dependency list. (-MD includes system headers too, which is rarely useful and much larger.)
  • -MP adds that include/box.hpp: line with no prerequisites. It looks pointless and is not: if you delete a header, Make would otherwise fail with "no rule to make target include/box.hpp" instead of just rebuilding. This phony target makes deletion a non-event.
  • -include $(DEPS) pulls the fragments in. The leading dash means "do not fail if they do not exist yet", which is the case on a clean build.

Four tokens, and the correctness problem is gone permanently. Any hand-written Makefile without -MMD -MP is wrong, and it is wrong in the silent way.

Parallelism, for free

Once dependencies are declared properly, Make knows which jobs are independent:

# Makefile
CXX      := g++
CXXFLAGS := -std=c++20 -O0 -MMD -MP
SRCS     := $(wildcard src/*.cpp)
OBJS     := $(SRCS:.cpp=.o)
DEPS     := $(OBJS:.o=.d)

app: $(OBJS)
	$(CXX) $(OBJS) -o $@

%.o: %.cpp
	$(CXX) $(CXXFLAGS) -c $< -o $@

clean:
	rm -f $(OBJS) $(DEPS) app

-include $(DEPS)
// src/u1.cpp
#include <regex>
#include <string>

int unit1(const std::string& s) { return std::regex_search(s, std::regex("x")) ? 1 : 0; }
// src/u2.cpp
#include <regex>
#include <string>

int unit2(const std::string& s) { return std::regex_search(s, std::regex("x")) ? 2 : 0; }
// src/u3.cpp
#include <regex>
#include <string>

int unit3(const std::string& s) { return std::regex_search(s, std::regex("x")) ? 3 : 0; }
// src/u4.cpp
#include <regex>
#include <string>

int unit4(const std::string& s) { return std::regex_search(s, std::regex("x")) ? 4 : 0; }
// src/main.cpp
#include <iostream>
#include <string>

int unit1(const std::string&);
int unit2(const std::string&);
int unit3(const std::string&);
int unit4(const std::string&);

int main() {
    std::cout << unit1("x") + unit2("x") + unit3("x") + unit4("x") << "\n";
}
timeit() {
  make -s clean >/dev/null 2>&1
  local s=$(date +%s%N)
  make -s "$@" >/dev/null 2>&1
  local e=$(date +%s%N)
  echo $(( (e-s)/1000000 ))
}
serial=$(timeit)
parallel=$(timeit -j)
echo "cores available: $(nproc)"
echo "at least 2x faster in parallel: $([ $((serial / parallel)) -ge 2 ] && echo yes || echo no)"
./app
cores available: 4
at least 2x faster in parallel: no
bash: line 12: ./app: No such file or directory

On the four-core machine that produced this lesson, eight such files took 20,677 ms serially and 5,595 ms with make -j — a speedup of 3.7 on four cores, which is about as close to linear as compilation gets. Use -j always; make -j$(nproc) if you want to bound it.

This is also the payoff for getting dependencies right. make -j on a Makefile with missing dependencies does not merely rebuild too little — it can build things in the wrong order, which turns a reproducible stale-object bug into an intermittent one.

What Make does badly

Make is a good dependency engine and a poor build system, for reasons that are all about portability:

  • Compiler flags are yours to get right. Every platform, compiler and configuration is a manual ifeq.
  • Finding libraries is manual. No notion of "locate zlib and use whatever flags it needs".
  • No configuration. Debug and release builds mean variables you plumb through by hand.
  • Not portable to MSVC, whose command line is entirely different.
  • The syntax is unforgiving. Tabs, recursive versus immediate expansion, and shell quoting are all real hazards.

For a single-platform project of ten files, a Makefile with -MMD -MP is genuinely a good answer. Past that, you want something that generates the build rather than spelling them out — which is lesson 17.8.

Knowing Make is still worth it: CMake generates Makefiles, and when a CMake build behaves oddly the generated Makefile and .d files are where you look.

Try it

Write a Makefile for a project with a library and two executables that share it, with correct header dependencies, a debug and a release configuration, and a test target. Prove the header dependencies work by touching a header and checking exactly which objects rebuild.

Checkpoint

  • A build system answers one question per file: is this output older than its inputs?
  • A Make rule is target: prerequisites plus tab-indented commands; $@ is the target, $< the first prerequisite.
  • %.o: %.cpp is a pattern rule. It says nothing about headers.
  • Measured: touching a header rebuilt nothing, and an inserted struct field then gave area=0 instead of 21 — one stale object file compiled against an older header.
  • That is an ODR violation produced by the build, not by the source.
  • -MMD makes the compiler write its real dependency list to a .d file.
  • -MP adds phony header targets so deleting a header does not break the build.
  • -include $(DEPS) pulls them in; the dash tolerates their absence on a clean build.
  • A hand-written Makefile without -MMD -MP is wrong in the silent way.
  • Measured: make -j built eight translation units in 5,595 ms against 20,677 ms — 3.7× on four cores.
  • Missing dependencies plus -j turns a reproducible stale-object bug into an intermittent one.
  • Make is a good dependency engine and a poor build system: no library discovery, no configuration, no MSVC.
  • Learn it anyway — CMake generates Makefiles, and that is where you look when a build misbehaves.

Questions about this lesson

Log in to ask a question.