At a very general level, makefiles allow us to define aliases for commands that we may run on our command line. We might have a really long command that we would like to use and modify over and over again.
For example, consider the following sequence of commands:
$ g++ file1.cpp file1.h -c
$ g++ file2.cpp file2.h -c
$ g++ main.cpp -c
$ g++ main.o file1.o file2.o -o app
Here is a depiction of the compilation process for a project which includes file1.cpp/h, file2.cpp/h and main.cpp. Most of the time, when we make a change to one of these files, it means we must recompile the project, by calling the above sequence of commands.
Makefiles give us the power to write an alias for this set of commands. We could configure a makefile such that, only writing something like:
$ make app
will call all of the commands needed to compile our source code into our executable. "app" in this case, is the makefile alias.
Throughout this, you may get confused by the compilation commands. Very briefly, I will cover some core ideas.
Compilation refers to the process of taking source code and converting it to something readable to your computer (binary). g++ is a compiler that we often use, along with clang++. The syntax for compiling is generally:
[compiler] [source files] [flags] [target]
Sometimes, we break our project down into helper files (like file1.cpp/h and file2.cpp/h) In cases like these, it may be ideal to compile these to object files and then link them later.
-o
stands for "compile and link". Not only will this compile source files to object files, it will link them together to form an executable you can run.
-c
stands for "compile only". This converts a source file to an object (.o) file, which you can link later.
Your friend is a chef. As a chef, they have a giant recipe book. Each recipe has a recipe name, a list of ingredients, and a step by step list of instructions of how to combine your ingredients in order to produce the recipe.
Now suppose you asked your friend to make some Cherry Pie!
Now back to your computer. A makefile is a recipe book and now your computer is the chef!
Now suppose you ask your computer to make an application called "app"
Again, every makefile is a list of recipes. Each recipe has a name, dependencies (ingredients) and commands (instructions) to execute.
The general syntax for a recipe is as follows:
recipeName: dependencyList
first command
...
last command
In our Cherry Pie example:
CherryPie: PieCrust Cherries
Lay Crust on Pie Pan
Pour Cherries into Pie Pan
Bake in Oven
And for our "app" example:
app: main.cpp file1.cpp file1.h file2.cpp file2.h
g++ file1.cpp file1.h -c
g++ file2.cpp file2.h -c
g++ main.cpp -c
g++ main.o file1.o file2.o -o app
For the second day in a row, you ask your friend to make a Cherry Pie. They scan their recipe book for "Cherry Pie", and take a look at the ingredients: PieCrust and Cherries. They look around in their kitchen, and to their horror, there is no pie crust to be found!
This would have been a disaster, but luckily, your friend happens to have a recipe for PieCrust in their recipe book! They use the PieCrust recipe, and then incorporate it into their CherryPie recipe.
In makefile format, this looks like:
CherryPie: PieCrust Cherries
Lay Crust on Pie Pan
Pour Cherries into Pie Pan
Bake in Oven at 375°
PieCrust: flour IceWater ... shortening
Mix flour IceWater ... shortening
Flatten into circular disk
So, your friend follows these steps:
In a practical makefile example we might have something like this when compiling our app.
app: main.o file1.o file2.o
g++ main.o file1.o file2.o -o app
main.o: main.cpp
g++ main.cpp -c
file1.o: file1.cpp file1.h
g++ file1.cpp file1.h -c
file2.o: file2.cpp file2.h
g++ file2.cpp file2.h -c
Notice that all we have to do to compile the app, is still the following command:
$ make app
This is because it will scan for main.o, notice it doesn't exist, then implicitly call make main.o, scan for file1.o, then implicitly call make file1.o, and so on. Once the makefile has made all of these dependencies, it runs the final compilation command to compile "app"
After years (two days) of experience, your friend has mastered the art of baking Cherry Pies! They have learned that the pie usually comes out better if the oven is set to 425° instead. In fact, they have learned that most of their recipes come out better at a higher temperature. But that would mean they would have to change the temperature manually for every single recipe...
Unfortunately in the real world with physical copy recipe books, this is the sad truth of making changes to recipes on a large scale. However! We have technology! The Makefile recipe book allows us to define variables and use them throughout our recipes!
So, your friend changes the baking Makefile to look like:
OVEN_TEMP=425°
CherryPie: PieCrust Cherries
Lay Crust on Pie Pan
Pour Cherries into Pie Pan
Bake in Oven at $(OVEN_TEMP)
PieCrust: flour IceWater ... shortening
Mix flour IceWater ... shortening
Flatten into circular disk to make PieCrust
ChocolateChipCookies: flour ... butter
Mix Dry Ingredients
Mix Wet Ingredients
Pour Dry Ingredients into Wet Ingredients
Bake in Oven at $(OVEN_TEMP)
In the case of compiling our app, we may make choices for our use of a compiler. g++ is one example of a compiler, as well as clang++. Sometimes we might want to compile with g++ or clang++ depending on each benefits, and our goal in compilation.
So, we can define our makefile in the following way:
CXX=g++
app: main.o file1.o file2.o
$(CXX) main.o file1.o file2.o -o app
main.o: main.cpp
$(CXX) main.cpp -c
file1.o: file1.cpp file1.h
$(CXX) file1.cpp file1.h -c
file2.o: file2.cpp file2.h
$(CXX) file2.cpp file2.h -c
Now if we ever want to switch from g++ to clang++, we just change the value of CXX to clang++.
Often times we have compilation flags. One common one is -std=c++11
which means "compile this program according to the c++11 standard". Another common flag is -Wall
which means "show me all warnings that are generated when compiling".
Instead of writing -std=c++11 -Wall
everywhere, we can define that too, as a variable:
CXX=g++
CXX_FLAGS=-std=c++11 -Wall
app: main.o file1.o file2.o
$(CXX) $(CXX_FLAGS) main.o file1.o file2.o -o app
main.o: main.cpp
$(CXX) $(CXX_FLAGS) main.cpp -c
file1.o: file1.cpp file1.h
$(CXX) $(CXX_FLAGS) file1.cpp file1.h -c
file2.o: file2.cpp file2.h
$(CXX) $(CXX_FLAGS) file2.cpp file2.h -c
For our pie crust recipe, you'll notice we have an instruction that says,
However, we are really just mixing all of the ingredients in our ingredients list! Your friend thinks writing this all out twice is unnecessary, and rewrites the recipe to say:
PieCrust: flour IceWater ... shortening
Mix Ingredients
Flatten into circular disk to make RecipeName
Now, when your friend reads this recipe, they know that you need to mix all of the ingredients. You will also notice that we have changed "to make PieCrust" to "to make RecipeName". RecipeName refers to... the name of the recipe, to be found to the left of the colon. So, RecipeName becomes a shortcut to PieCrust
When compiling our app, you notice we also have this same problem of restating our dependencies (ingredients). So, we can simplify our make commands in the following way:
CXX=g++
app: main.o file1.o file2.o
$(CXX) $^ -o $@
main.o: main.cpp
$(CXX) $^ -c
file1.o: file1.cpp file1.h
$(CXX) $^ -c
file2.o: file2.cpp file2.h
$(CXX) $^ -c
Notice that $^
is our way of saying "Ingredients" and $@
is our way of saying "RecipeName"
Your friend is quite meticulous when it comes to baking. Every time your friend bakes something from their recipe book, they keep track of the freshness of every ingredient they used. So, every ingredient that appears in their recipe book has an associated freshness level, indicated via a timestamp. So, if our friend makes CherryPie at 11:00 a.m., PieCrust, along with all of its ingredients, has its freshness level updated to: 11:00am. Cherries are also updated in the same way.
Now, lets say you ask your friend, for the third day in a row, to make you a Cherry Pie. Your friend looks at the Cherry Pie they made yesterday and says,
"Hmm... I already have this Cherry Pie that I made you yesterday, and all the ingredients in my pantry are of the same freshness as when I made the pie. Therefore, this pie is the freshest it can be. There is no need to make a new Cherry Pie, you can have this one instead!"
So, your friend decides NOT make a new Cherry Pie and serves you the already existing one.
However, the next day, your friend takes a trip to the store and buys a brand spanking new can of Cherries.
When they get back, you ask them, for the fourth day in a row, for a Cherry Pie. Your friend consults their ingredient list and says,
"Hmmm... I already have this Cherry Pie that I made you yesterday. However, these Cherries that I just bought from the store are fresher than the Cherries in that pie! Therefore, I will make you a brand new pie with these new Cherries I bought, so that you may experience the most freshest Cherry Pie possible!"
So, your friend DOES decides to make a new Cherry Pie and serves it to you.
Makefiles in the programming world work in the same exact way. In our app example, if you run: make app
, makefile will remember the timestamps of each file at the time the command was ran. Timestamps only change if a change is made to the file. So, rather than making every recipe from scratch every time, makefile only re-makes the recipes which are outdated, saving on compilation time in most cases.
Lets be a little more specific by considering our running makefile:
CXX=g++
app: main.o file1.o file2.o
$(CXX) $^ -o $@
main.o: main.cpp
$(CXX) $^ -c
file1.o: file1.cpp file1.h
$(CXX) $^ -c
file2.o: file2.cpp file2.h
$(CXX) $^ -c
Suppose we start fresh and run make app
. The last time file1.cpp was changed was 11:00 a.m. and the last time file2.cpp was changed was 11:00 a.m. So, the makefile remembers these respective timestamps after the first make app
call. Since it is the first time, the makefile implicitly makes every recipe: main.o, file1.o, and file2.o.
Now, if we run make app
again without making any changes, Makefile will say:
make: `app' is up to date.
Now, lets make a change to file1.cpp at 11:05 a.m. and run make app
.
You will notice makefile only outputs:
g++ file1.cpp file1.h -c
g++ main.o file1.o file2.o -o app
The makefile is NOT running:
g++ file2.cpp file2.h -c
g++ main.cpp -c
This is because main.o and file2.o is already up to date, so there is no need to recompile them. As mentioned before, this saves on compilation time! This is a big deal because sometimes compilation can take a while, and you'd rather not recompile files that don't need to be recompiled.
This recipe book is pretty weird becaues, as we've seen, some recipes refer to other recipes (as in CherryPie and PieCrust).
This means, asking your friend to bake a single recipe, can cause any number of other recipes to also be made.
Now suppose you are planning a three course meal.
Instead of asking your friend to first make GarlicBread, then make Spaghetti, and then make CherryPie (for the fifth day in a row), you can simply ask your friend, "Could you please make a three course meal". Three course meal in this case, is acting as its own "recipe", whose ingredients are other recipes in the recipe book. So, our makefile could look like:
OVEN_TEMP=425°
threeCourseMeal: GarlicBread Spaghetti CherryPie
GarlicBread: garlic bread butter
mix butter and garlic, spread on bread
bake bread in oven at $(OVEN_TEMP)
Spaghetti: SpaghettiSauce spaghettiNoodles
Mix SpaghettiSauce with spaghettiNoodles
CherryPie: PieCrust Cherries
Lay Crust on Pie Pan
Pour Cherries into Pie Pan
Bake in Oven at $(OVEN_TEMP)
Now, the command: make threeCourseMeal
, will implicitly make all three components of the meal, including GarlicBread, Spaghetti, and CherryPie.
As you might guess, this is something we often take advantage of as Makefile users.
Consider the case where we have an "app" program along with a "testapp" program. Instead of running make app
and make testapp
separately, by convention we usually use a recipe called "all" like so:
CXX=g++
all: app testapp
testapp: test.o
$(CXX) $^ -o $@
app: main.o file1.o file2.o
$(CXX) $^ -o $@
main.o: main.cpp
$(CXX) $^ -c
test.o: test.cpp
$(CXX) $^ -c
file1.o: file1.cpp file1.h
$(CXX) $^ -c
file2.o: file2.cpp file2.h
$(CXX) $^ -c
Notice how there are no instructions for the "all" recipe, since it is only used to call other recipes.
After all the CherryPies your friend has made you, their kitchen is a mess! Your friend is a chef though, and tries to make everything, even everyday actions, into recipes. So, they have a recipe called "cleanKitchen" which is a recipe for cleaning their kitchen!
OVEN_TEMP=425°
CherryPie: PieCrust Cherries
Lay Crust on Pie Pan
Pour Cherries into Pie Pan
Bake in Oven at $(OVEN_TEMP)
PieCrust: flour IceWater ... shortening
Mix flour IceWater ... shortening
Flatten into circular disk to make PieCrust
ChocolateChipCookies: flour ... butter
Mix Dry Ingredients
Mix Wet Ingredients
Pour Dry Ingredients into Wet Ingredients
Bake in Oven at $(OVEN_TEMP)
#... other recipes, # are comment tags by the way!
cleanKitchen:
Dispose of excess CherryPie, PieCrust, and ChocolateChipCookies
Notice how there are no ingredients for this action.
So now you ask your friend to make cleanKitchen
... but OH NO! your friend's pantry contains a real ingredient called "cleanKitchen". Maybe it isn't such a good idea to have recipes for every day actions.
When you ask them to make cleanKitchen
your friend gets confused because according to the freshness list, cleanKitchen (the ingredient) is up to date. So instead of cleaning the kitchen, your friend doesn't do anything. Indeed your friend is having an existential crisis.
To avoid this catastrophic event, your friend inserts a note to themselves in the recipe book, to remove the cleanKitchen entry from the freshness list.
OVEN_TEMP=425°
CherryPie: PieCrust Cherries
Lay Crust on Pie Pan
Pour Cherries into Pie Pan
Bake in Oven at $(OVEN_TEMP)
PieCrust: flour IceWater ... shortening
Mix flour IceWater ... shortening
Flatten into circular disk to make PieCrust
ChocolateChipCookies: flour ... butter
Mix Dry Ingredients
Mix Wet Ingredients
Pour Dry Ingredients into Wet Ingredients
Bake in Oven at $(OVEN_TEMP)
#... other recipes, # are comment tags by the way!
cleanKitchen is a PHONY!!!
cleanKitchen:
Dispose of excess CherryPie, PieCrust, and ChocolateChipCookies
Now when your friend consults the recipe book, they know that cleanKitchen should actually refer to an everyday action instead of a real-life ingredient. Knowing that this is an everyday action means you don't need to consult the freshness entry.
In a makefile, we also may have a clean
recipe which removes files that would otherwise clutter our workspace.
CXX=g++
targets=all testapp
all: $(targets)
testapp: test.o
$(CXX) $^ -o $@
app: main.o file1.o file2.o
$(CXX) $^ -o $@
main.o: main.cpp
$(CXX) $^ -c
test.o: test.cpp
$(CXX) $^ -c
file1.o: file1.cpp file1.h
$(CXX) $^ -c
file2.o: file2.cpp file2.h
$(CXX) $^ -c
.PHONY: clean all
clean:
rm -f *.o $(targets)
Note that all is included in the .PHONY "recipe" since it too refers to an "abstract everyday event". "all", like "clean", is not associated with any particular result, and is only used to call other recipes.
rm -f *.o $(targets)
is used to remove all files ending with a ".o", with the "*" character acting as a wildcard to match any pattern. $(targets) is invoked to remove all our executables. Finally, -f
is a flag for rm
, which stands for "force". Meaning, we want to remove these files without any further confirmation.
To get the idea of a makefile across, here is another, non-compilation, based application of Makefiles. Remember, Makefiles act as a place for us to create aliases to commands.
Have you ever been so tired of the same three git commands over and over again?
git add .
git commit -m ":3"
git push origin main
Yea me too... I wish there was a way to invoke all these commands at once...
But then you realize, I can make a Makefile recipe for this! So you run to your makefile and type this in:
gitpush:
git add .
git commit -m ":3"
git push origin main
Notice that gitpush does not have any dependencies. All you have to do it type in the command:
$ make gitpush
and your wishes will be executed.
The gist is: Makefiles are a powerful tool to help us compile our c++ programs. Happy Makefiling!