2 - Understanding what Makefiles are

This blog post is part of the series I just made up called “I’m not really sure, but I should know how this thing works if I’m a developer”. That’s trademarked, just so you know.

So recently I was playing around with some Linux and on a couple of occasions while surfing Stack Overflow, I saw a few examples of people solving problems by using make. I thought to myself “Wow, all these smart ass programmers here sure sound like they know what they are talking about. Maybe I should look into what make does and how I can use it”. <— [Edit: I kinda regret this] Let’s try to understand what make is, how to write a Makefile and how useful can it be.

The super high level explanation

Make is a utility in Unix (on top of which MacOS and Linux are built). This utility is made to start the execution of a makefile / Makefile, (naming is dependant on your system, but you get the gist). What is a Makefile you might ask? A Makefile is a file that has some commands inside. When you are inside the same directory you have your Makefile saved, you can run make and the commands in the file will be executed.

Let’s make a very simple example.

mkdir make-playground
cd make-playground/
touch Makefile

We’ve created a directory and an empty Makefile inside. Cool. Let’s edit the Makefile and add something to it we can execute.

complicated_example:
  echo "This is a very complicated example"

Nice one. Remember what we said earlier? If we are in the same directory and we run make in the terminal, the commands inside the Makefile will be executed. Let’s give that a try.

make

And we get the following output:

echo "This is a very complicated example"
This is a very complicated example

Let’s break this down:

complicated_example is like we’re defining a function in any programming language. This is called the target. The prerequisites or the dependencies follow the target. We have not yet defined any prerequisites in our example. The echo "This is a very complicated example" command is called the recipe. The recipe uses the prerequisites to make a target. The target, prerequisites and the recipe all together make a rule.

This is a bit mindfucky to read, I know, so let’s try to visualise it with some pseudo code:

target: prerequisites
  <TAB> recipe

A target could be a binary file that depends on prerequisites (source files). A prerequisite can also be a target that depends on other dependencies. Yeah I know, I am also confused as fuck. Let’s try out another visualisation:

final_target: sub_target final_target.c
        Recipe_to_create_final_target

sub_target: sub_target.c
        Recipe_to_create_sub_target

It is not necessary for the target to be a file; it could be just a name for the recipe, as in our example. Apparently these are called “phony targets.”

Ok, so enough with the mindfucky stuff, let’s go back to the comfy simple “Hello world” style example from above. Remember the output we got when we ran make? Don’t worry about scrolling up to find it, here it is:

echo "This is a very complicated example"
This is a very complicated example

As you can see, we saw the command (recipe) itself too. I don’t want to see the recipe, I want to see the recipe in action. Let’s surpress that.

Let’s edit our Makefile:

complicated_example:
  @echo "This is a very complicated example"

We surpress echoing the actual command by using @. Now if we run make we should see the following output:

This is a very complicated example

Let’s expand our Makefile to do more than just echo something back to us:

complicated_example:
  echo "This is a very complicated example"

file_creator:
  @echo "Creating a bunch of files"
  touch {1..10}.txt

file_destroyer:
  @echo "Begone you mortal files!"
  rm **.txt

Alright, nice, so we have a couple of targets now that will first create 10 files ({n1..n2} is how you make an array in bash), and then if I need to tell you what our file_destroyer does, maybe you should be reading a different genre of blogs. Seriously though.

Let’s run it!

make

Hmmm. I don’t see any files created. I don’t see the echo either. I only see the first target’s output, what is happening?

So by default, when you run make, the very first rule in the Makefile gets executed, and that’s pretty much it. A bit anticlimactic, right? Let’s see how we can fix this behaviour, but before that, let’s sneak in some more learnings:

We can specify the default command when we run make like so:

.DEFAULT_GOAL := file_creator

Add this to the top of the Makefile and run make.

.DEFAULT_GOAL is pretty self-explanatory. We say what’s the default target we want to run when we call make. But most of the time, you’ll actualy be running multiple commands. Or not, I’m not your dad, you can do whatever you want. (Seriously though, in reality, you’ll run multiple commands). Let’s learn how to tell the Makefile what to run:

all: complicated_example file_creator

complicated_example:
  echo "This is a very complicated example"

file_creator:
  @echo "Creating a bunch of files"
  touch {1..10}.txt

file_destroyer:
  @echo "Begone you mortal files!"
  rm **.txt

Now, when we run make, we should see the following output (+10 freshly created empty .txt files):

This is a very complicated example
Creating a bunch of files
touch {1..2}.txt

Great! Now we know how to tell the Makefile which targets to execute by default!

We can also call specific targets with the following syntax: make file_destroyer:

Begone you mortal files!
rm **.txt

Makefiles can get as simple and as complicated as your usecase. After spending a couple of days researching, I found out that that are entire books written on make and that using it to compile c programs is just the tip of the iceberg. There are patterns, variables, functions, and it can get real serious real fast.

I’ll try and leave you with a practical example that can utilise our base knowledge we’ve gained. Let’s say we are making a lil Flask app that uses Docker, and we are too lazy to type docker-compose up docker-compose down, etc.

Let’s see how the Makefile for that will look like:

serve:
  docker-compose up -d myservice

stop:
  docker-compose down

test:
  docker-compose run myservice pytest

seed:
  docker-compose run myservice python3/bin/manage.py seed_db

Stick this Makefile in your base directory, and now you can simplify your flow by just running make <target>! We have a couple of examples here that will run our app, stop it, run some tests or seed our local database.

I hope this gave you a base understanding of how make works and that you can implement it in your life to make some tasks easier. Or you might go ahead and start compiling binary files to C programs. The sky is the limit.

Until next time!


Written by Emil Mladenov - a slavic software developer who decided to use a blog as a digital rubber duck

I also have a podcast