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: %.cppis a pattern rule: it explains how to make any.ofrom 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:
-MMDwritessrc/vol.dalongside the object, containing the real dependency list. (-MDincludes system headers too, which is rarely useful and much larger.)-MPadds thatinclude/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 targetinclude/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: prerequisitesplus tab-indented commands;$@is the target,$<the first prerequisite. %.o: %.cppis a pattern rule. It says nothing about headers.- Measured: touching a header rebuilt nothing, and an inserted struct field then gave
area=0instead of 21 — one stale object file compiled against an older header. - That is an ODR violation produced by the build, not by the source.
-MMDmakes the compiler write its real dependency list to a.dfile.-MPadds 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 -MPis wrong in the silent way. - Measured:
make -jbuilt eight translation units in 5,595 ms against 20,677 ms — 3.7× on four cores. - Missing dependencies plus
-jturns 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.