Makefile language is a functional, dynamically typed language, and of course, it is Turing complete. It’s probably the most popular, unpopular programming language, widely adopted in many programming projects (especially ones using C as the main language). While it shares many features with other functional languages, it has rather an unorthodox syntax.
In the following tutorial, I would be assuming that you are using a Unix-like operating system. GNU Make is probably already preinstalled, so you don’t need to do anything.
The code examples can be found here.
Variables
Makefiles have two flavors of variables. The simple expanded variables work like ordinary variables, we can assign values to them (:=
), access, and modify their values. Let’s take the following code as an example:
GREET := Hi
main:
$(GREET) World echo
Makefile does not have a REPL environment, so to run it we first need to save it into a file, we’ll use variables.mk file for it. Next, we can run it using the make
command:
$ make -f variables.mk
echo Hi World
Hi World
Alternatively, a variable can be passed to the program when calling it:
$ make -f variables.mk GREET=Hello
echo Hello World
Hello World
As you can see, the variable assignments are only used to define the default values. To set variables that could not be changed by the user, use the overwrite
keyword.
Another kind of variable is the recursively expanded, assigned with =
. Those variables behave similarly to pointers because they can point to other variables and will change when the variables they refer to change. This is best illustrated with an example.
GREET = $(MESSAGE)
MESSAGE := Hi
main:
@ echo $(GREET) World # with @ the command is not printed
If we used instead
- GREET = $(MESSAGE)
+ GREET := $(MESSAGE)
and didn’t provide the value for neither GREET
nor MESSAGE
, GREET
would be null by default and print as an empty string, like below.
$ make -f variables.mk
World
However with GREET = $(MESSAGE)
, it expands to the value it points to:
$ make -f variables.mk
Hi World
$ make -f variables.mk GREET=Hola
Hola World
$ make -f variables.mk MESSAGE=Hello
Hello World
$ make -f variables.mk GREET=Hola MESSAGE=Hello
Hola World
Variables are defined by a convention on the top of the Makefile. They cannot be modified within the functions, the code below won’t work.
invalid:
MESSAGE := Hello@ echo $(MESSAGE) World
$ make -f variables.mk invalid
MESSAGE := Hello
make: MESSAGE: No such file or directory
make: *** [invalid] Error 1
What this means for us, is that we need to treat the data as immutable and embrace the functional programming style.
Lists
Like other lisps, Makefile natively supports list data structures and has several methods for interacting with lists:
$(words $(LIST))
returns the size of theLIST
,$(firstword $(LIST))
returns the first element of theLIST
,$(word $(N), $(LIST))
returnsN
th element of theLIST
,$(wordlist $(START), $(END), $(LIST))
returns elements fromSTART
toEND
(inclusive),variable += value
addsvalue
tovariable
, treatingvariable
as a list.
To do (cons cat cdr)
as in Scheme, in Makefile we just need to construct it is a regular string "$(CAT) $(CDR)"
.
Lists are passed as strings with elements separated by spaces, LIST="1 2 3 4"
is a list of four elements. Unlike other lisps, Makefile does not have native support for lists of lists or other data structures, so implementing them is left as an exercise for the user.
Makefile has foreach
and filter
methods for working with lists that may be known to users of other functional programming languages (foreach
corresponds to map
). To implement the function that reduces the list, we need to use recursion.
Functions
Functions (or targets as they are called) in Makefile are defined by writing their name followed by :
, where the code starts from the next line indented with tab (sorry space users). The syntax might feel familiar to Python, Haskell, or OCaml programmers.
GREET := Hello
hello:
@ echo "$(GREET) World!"
date:
@ echo $(shell date)
By default, Makefile assumes the first function in the file to be the main entry point (like the main
function in Go).
$ make -f functions.mk
Hello World!
$ make -f functions.mk date
Tue 4 Jan 20:47:28 CET 2022
We can of course call functions from other functions. For example, we could define another function that would invoke the two functions above.
message:
@ $(MAKE) -f functions.mk hello GREET=Hi
@ echo "It's" "$(shell $(MAKE) -f functions.mk date)"
To call the other functions, we used the $(MAKE)
command. If directory contains only one Makefile, $(MAKE)
alone can be used, but when there are multiple Makefiles available, we need to name the specific file. In the first line of the function, we called make alone, but in the second line its output was passed as an argument to another function, so we needed to call it with $(shell command)
(see below).
The example above shows also how Makefile code can be grouped into packages (files) and imported. The -f
syntax helps to distinguish the source of the function like Go (e.g. fmt.Println
) or Python (e.g. datetime.datetime
) do use the dot instead.
Remember that each line of the function is executed in its own subshell so they don’t share their states. This can be changed using .ONESHELL
target.
Chained functions
Makefiles support chained execution of the functions, where a function can define its dependencies that will be called before executing it.
numbers: one two three
one:
@ echo One
two:
@ echo Two
three:
@ echo Three
Since the functions don’t share any state, the only way to pass data between them is through writing and reading files.
Conditionals
Makefile has four conditional statements: ifeq
(two values are equal), ifneq
(two values are not equal), ifdef
(value is defined), and ifndef
(value is not defined). The two latter methods, check if the variable was defined rather than if its value was set. If you want to check for an empty value, use
ifeq ($(VARIABLE), )
# do things
endif
The conditional statements can be chained
conditional-directive-one text-if-one-is-true
else
conditional-directive-two text-if-two-is-trueelse
text-if-one-and-two-are-falseendif
The statements can sometimes be tricky. For example, calling make -f conditionals.mk invalid
won’t work
VARIABLE := 1
cond:
ifeq ($(VARIABLE), 1)
echo 1else
echo 0endif
invalid:
ifeq ($(shell $(MAKE) -f conditionals.mk cond), 1)
"it's 1"
echo else
"it's not 1"
echo endif
because Makefile has a rather specific order of executing and expanding stuff. In fact, the Makefile with invalid
rule won’t work at all, because Makefile won’t be able to parse a script that recursively calls make
in the conditional statement. Instead, use something like the below:
impl:
ifeq ($(CONDITION), 1)
"it's 1"
echo else
"it's not 1"
echo endif
valid:
$(MAKE) -f conditionals.mk impl CONDITION="$(shell $(MAKE) -f conditionals.mk cond)"
By doing this, we first call cond
and pass the result to the CONDITION
variable that is evaluated in the ifeq
condition.
Since Makefile does not come with other comparison operators than checking for equality and inequality, they need to be implemented by the user using Bash test
command (or something else).
less = $(shell test "$(1)" \< "$(2)"; echo $$?)
isless:
ifeq ($(call less, $(A), $(B)), 0)
@ echo "$(A) < $(B)"
else
@ echo "$(A) >= $(B)"
endif
Conditionals can be used also outside functions when defining variables.
Recursion
Makefile has a very simple syntax. Same as lisp, it doesn’t have for loops and instead we need to use recursion. Consider the simple recursive function that sums elements of a LIST
:
TOTAL := 0
HEAD := $(firstword $(LIST))
TAIL := $(wordlist 2, $(words $(LIST)), $(LIST))
sum:
ifeq ($(LIST), )
@ echo $(TOTAL)
else
$(MAKE) -f sum.mk LIST="$(TAIL)" TOTAL=$(shell expr $(TOTAL) + $(HEAD))
endif
As you can see, the function uses the accumulator pattern, common in functional programming languages. It iterates over LIST
, chopping off elements from the beginning of the array and adding them to TOTAL
variable that is printed at the end.
When using recursion, it may be a good idea to add MAKEFLAGS += --no-print-directory
to the script. It will silence the Make messages informing about every new call to make
.
Unit testing
While Makefile does not come out of a box with unit testing utilities, it can be easily implemented by the user. An example is shown below.
testme:
@ echo $(shell expr 2 + 2)
assert:
ifeq ($(RESULT), $(EXPECTED))
$(info "OK!")
else
$(error "Test failed")
endif
test-testme:
$(MAKE) -f assert.mk assert RESULT="$(shell $(MAKE) -f assert.mk testme)" EXPECTED=4
Metaprogramming with macros
While Makefile’s metaprogramming utilities are not as impressive as with some lisps, they still can be quite helpful. Makefile supports macros in two forms. We saw one form of macros in the section on conditionals when defining the less
macro that was called using $(call macro,args...)
. You might have noticed that in fact less
is a named lambda function.
The second kind of macro is declared using the define
block and called the same way as variables. The second kind of macro is snippets of code that get pasted to the code at the execution time.
define date
$(shell date)
endef
sum = $(shell expr $(1) + $(2))
main:
@ echo "Today is" "$(date)"
@ echo "2 + 2 =" $(call sum,2,2)
Interacting with other programming languages
Makefiles can interact with any program that can be called from the command line. To do this, just use the $(shell command)
that executes the command
in Shell. This can be used for expanding conditionals or for arithmetics using expr
, etc.
Examples
To give a slightly more complicated example, below I show the implementation of Quicksort algorithm in Makefile. The code uses variables, calling internal functions with $(MAKE)
, conditionals, and recursion.
MAKEFLAGS += --no-print-directory
HEAD := $(firstword $(LIST))
TAIL := $(wordlist 2, $(words $(LIST)), $(LIST))
lt = $(shell test $(1) \< $(2); echo $$?)
sort:
@ $(MAKE) impl LIST="$(TAIL)" PIVOT="$(HEAD)" LEFT= RIGHT=
impl:
ifeq ($(PIVOT), )
@ echo
else ifeq ($(LIST), )
@ echo $(shell $(MAKE) LIST="$(LEFT)") $(PIVOT) $(shell $(MAKE) LIST="$(RIGHT)")
else ifeq ($(call lt, $(HEAD), $(PIVOT)), 0)
@ $(MAKE) impl LIST="$(TAIL)" LEFT="$(LEFT) $(HEAD)" PIVOT="$(PIVOT)" RIGHT="$(RIGHT)"
else
@ $(MAKE) impl LIST="$(TAIL)" LEFT="$(LEFT)" PIVOT="$(PIVOT)" RIGHT="$(HEAD) $(RIGHT)"
endif
Another example shows a tail-recursive implementation of the Fibonacci sequence generator. Unfortunately, to my best knowledge Makefile does not support tail call optimization as many functional programming languages do, so it would be rather slow. The example also shows a limitation of integer type numbers, as it will overflow when using NUMBER
over 91.
MAKEFLAGS += --no-print-directory
NUMBER:=0
CURRENT:=0
NEXT:=1
fibo:
ifeq ($(NUMBER), 0)
$(CURRENT)
echo else
$(MAKE) NUMBER=$(shell expr $(NUMBER) - 1) CURRENT=$(NEXT) NEXT=$(shell expr $(CURRENT) + $(NEXT))
endif
For other examples, here you can find tic-tac-toe game implemented in Make, and here someone implemented integer arithmetics in pure Makefile (no expr
or Bash).