Building Systems With Make
- Introduction
- Simple Example
- Terminology
- Prerequisites
- How Make Decides What To Do
- Automatic variables
- Commands
- Targets As Prerequisites
- Accessing Targets
- Parsing Targets And Prerequisites
- Dynamic Targets
- Dereferencing (Variables and Macros)
- Functions
- User-Defined Functions
- Conventions
- Revisiting The For Loop Example
- Includes
- Conclusion
Introduction
Most web developers use a build tool of some sort nowadays. I’m not refering to continuous integration software like Jenkins CI (a very popular build system), but the lower-level software it uses to actually acquire dependencies and construct your applications with.
There is a dizzying array of options to choose from:
- Apache Ant (XML-based)
- Rake (Ruby-based)
- Grunt (JS-based)
- Gulp (JS-based)
- Broccoli (JS-based)
- NPM (JS-based)
- Good ol’ shell scripts (although no real orchestration around it)
The build tool I want to look at in more detail here though is the granddaddy of them all: Make.
Originally designed back in 1976, Make is the leading build utility for Unix, Linux and Mac OS X. Chances are, most computers you log in to will already have it installed and available to use. This really reduces the set-up entry point (which for other tools listed above can be tedious and error prone – with the exception of shell scripts, as the shell is something inherently available for all systems).
My hope is for you to see that Make is an automation/orchestration tool that can be used in place of other modern build tools, and will help to strengthen your understanding and ability to use the terminal/shell environment (which is a big plus in my opinion, and helps open up many avenues of technical progression).
I couldn’t hope to cover every aspect of what Make offers, so please don’t mistakenly consider this post as anything even remotely exhaustive. Whole books have been written on the topic of Make and writing Makefiles so I’ll leave it up to you to investigate further beyond this post if I’ve managed to kindle your interest.
Let me start by referencing the GNU website for its definition of what Make is and does:
GNU Make is a tool which controls the generation of executables and other non-source files of a program from the program’s source files
Make relies on a Makefile being defined and which consists of a set of instructions for building your software. If you’ve used another build system, such as Grunt, you’ll notice that most of them use a naming convention taken from Make (e.g. Gruntfile).
The point of a Makefile (in the traditional sense) is to build a program; although Make can be used to run any kind of task and so it isn’t limited to compiling software. Much like how other JavaScript-based build tools aren’t limited to building JavaScript applications, they can handle most tasks you wish to run (maybe compiling CSS or optimizing images).
You’ll find Make is widely distributed and is likely already on your computer. For example, I’m using an Apple laptop with Mac OS X installed. If I run the following command:
make --version
I get back the following response:
GNU Make 3.81
Copyright (C) 2006 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.
There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE.
This program built for i386-apple-darwin11.3.0
Which means I already have the make
command available and I can start writing my Makefile right away.
Simple Example
Let’s consider a standard project requirement, which is to run a linter such as JSHint over a JavaScript file (that is, analyze the code for formatting issues and general errors and warnings).
Note: as mentioned earlier, traditionally Make is used to compile program files. In this instance I’ve opted for a simple example that doesn’t require compilation but should instead demonstrate how Make is actually useful for many different types of task.
Imagine you have a test.js file and it contains the following content:
function foo() {
bar = "baz"
}
If we were to execute the command jshint test.js --show-non-errors
(assuming you have the CLI version of JSHint installed) then we should see something like the following displayed:
test.js: line 2, col 14, Missing semicolon.
1 error
test.js :
Implied globals:
bar: 2
Unused Variables:
foo(1),
So we can see from this output that JSHint is warning us that we have a function foo
that’s not being used and a variable that appears to have been declared globally; but it also indicates that we have an error in our program: we’re missing a semicolon from line 2 in our JavaScript file.
OK great, so how do we take this example further and automate the analysis process (which will get more complicated as our application grows in size and features) using the Make utility?
First we need to create a Makefile. Below are the contents of the Makefile I’m going to use to demonstrate how Make works (I’ll explain the structure of the file in the next section):
lint
jshint *.js --show-non-errors
Note: Makefiles use tabs instead of spaces, so if your editor is set up to replace spaces with tabs then you could find things don’t work as expected
To run the Makefile above, we would need to use the make
shell command. This by itself will run the first target it finds (this is also referred to as the default target) which in this case is lint
. You can also be more explicit and specify the exact target you want to execute by providing the name of the target to the make
command, like so:
make lint
Executing the above command is the same as running:
jshint test.js --show-non-errors
You’ll also have noticed we used a wildcard *
to indicate multiple JavaScript files at once.
In this instance, using Make means it’s easier to remember specific commands for common tasks such as this. Having to remember the format of the JSHint shell command is now not an issue, especially considering that I’m using the most bare bones example of running JSHint, and the shell command itself can become much longer and unwieldy.
The Makefile also acts as a documented file that can now be committed into version control, meaning we now have a record of the compilation step. Both these points become even more important as the compilation/build steps become more and more complicated, which they will as your application or software system naturally grows and evolves.
Note: if your Makefile is in a different directory, you can pass its location to the
make
command using the-f
flag like so:make -f <makefile>
The convention for writing Makefiles is to have the default command (your entry point) at the top of the file and have Make process the commands from the top down. You don’t have to do this, though (as you’ll see, I’ve not really worried about it with the examples throughout this post), and you’re free to put your rules in whatever order makes sense to you. But be aware that when you call the Make command, you’ll want to specify the specific target if it’s not the default.
Terminology
There are three key phrases you need to be aware of when talking about a Makefile:
- Rules
- Targets
- Prerequisites
The following snippet demonstrates the basic structure of a Makefile:
target: prereq1 prereq2
commands
You can see we have: a single target (this is what we reference when running the command make <target>
); a set of dependencies (i.e. prerequisites); and a command to execute (e.g. jshint test.js --show-non-errors
). This entire structure is collectively referred to as a “rule” and a Makefile is typically made up of multiple rules.
Prerequisites
Prerequisites are the dependencies for the target. What this means is that the target cannot be built successfully without the dependencies first being resolved.
Imagine we’re compiling Sass into CSS. An example Makefile (which we’ll look at in more detail shortly) could look like:
compile: foo.scss
sass foo.scss foo.css
In the above example we specified the prerequisite as being foo.scss
; meaning Make will either look for a target called foo.scss
or expect a file to exist in the current directory structure.
We don’t have a target named foo.scss
and so if that file also didn’t exist, then we couldn’t resolve the dependency and subsequently the rule would fail (if it can’t resolve the dependency then the command in the rule won’t be executed).
How Make Decides What To Do
How and why Make decides what to do when you run make <target>
is very important as it’ll help you understand the performance implications of certain tasks. The rule of thumb for Make is pretty simple: if the target (or any of its prerequisite files) are out of date or missing, then the commands for that target will be executed.
Make uses the modification timestamp to avoid duplicate processing. If the timestamp of the dependent files is older than the resulting output, then running Make won’t do anything. Hence you can force Make to recompile a file by simply using the touch
command on the relevant files.
Note: if you want to see what Make will execute without it actually doing anything, then run the
make
command as you normally would but ensure you include the-n
flag. This will cause Make to print out all commands that would be executed, including commands collated from any specified prerequisites.
Automatic variables
Let’s consider another example whereby we want to compile a Sass style sheet into CSS:
compile: foo.scss
sass foo.scss foo.css
We have some slight duplication here, the reference to foo.scss. We can clean this up a bit by using some special variables that Make provides (also referred to as automatic variables). Specifically for the problem we want to solve, we’ll be using the $<
automatic variable.
When the compile
target is run, the $<
variable will reference the first prerequisite in the list, which will simplify the example and save you from having to repeat yourself. The following example demonstrates what this looks like:
compile: foo.scss
sass $< foo.css
This is good because we’ve removed a hardcoded value and made our code slightly more flexible. But what happens if we have multiple dependencies?
Assume we have three files foo.txt, bar.txt and baz.txt. We can use a combination of the $^
variable (which gives us all the dependencies/prerequisites as a list) and a small bit of standard Bash shell code (Make commands are ultimately structured shell scripts with extra syntactical sugar) to loop over the provided dependency list.
The following example demonstrates how this could be written:
list: foo.txt bar.txt baz.txt
for i in $^; do echo "Dependency: $$i"; done
Executing make list
would result in the following response:
for i in foo.txt bar.txt baz.txt; do echo "Dependency: $i"; done
Dependency: foo.txt
Dependency: bar.txt
Dependency: baz.txt
Note: because Makefiles have their own special syntax, the use of
$
will conflict when writing our shell script (which also has its own special syntax around$
). This means if we want to use the dollar character and not have it be Makefile specific, then we have to escape it using another dollar. So rather than writing$i
– which works fine within the context of a normal shell script – we’ve had to write$$i
instead.
We’ll see a few different automatic variables throughout this post, but in the meantime check out the quick reference list below for some of the more useful ones:
$<
: first prerequisite$^
: list of prerequisites$?
: list of prerequisites that have changed$@
: target name$*
: the value of a target placeholder
The full reference of automatic variables is available on the GNU Make website.
Later on in this post we’ll revisit this for
loop example and demonstrate a more idiomatic way to achieve the result we want.
Commands
It’s worth being aware that each command provided inside the overall rule is considered a separate shell context. This means if you export a shell environment variable in one command, it won’t be available within the next command. Once the first command has finished, a fresh shell is spawned for the next command, and so on.
You’ll also notice that when running Make it will print out the command instructions before executing them. This can be disabled in one of three ways. You can either run Make with the -s
flag, which will silence any output; or you can use the @
syntax before the command itself, like so:
list: foo.txt bar.txt baz.txt
@for i in $^; do echo "Dependency: $$i"; done
The third way to silence output is to use the .SILENCE
flag. The following snippet demonstrates how to silence three targets: foo
, bar
and baz
:
.SILENT: foo bar baz
Note: silencing the output unfortunately also means silencing any errors!
Much like shell scripting, if you have a command that is more complicated than what can feasibly fit on a single line, then – for the sake of readability if nothing else – you’ll need to write it across multiple lines and escape the line breaks using the \
character, as the following example demonstrates:
list: foo.txt bar.txt baz.txt
for i in $^; do \
echo "Dependency: $$i"; \
done
Targets As Prerequisites
So far our prerequisites have been physical files that already existed. But what if you need to dynamically create the files first via other targets? Make allows you to specify targets as dependencies, so that’s not a problem. Let’s see how this works in the following example:
foo:
@echo foo > foo-file.txt
bar:
@echo bar > bar-file.txt
baz: foo bar
@echo baz | cat - foo-file.txt bar-file.txt > baz-file.txt
Note: Make typically uses the convention of naming targets after the files they create. This isn’t a necessity but it’s generally considered good practice
What we have are three targets: foo
, bar
and baz
. The first two have no dependencies of their own and all they do is generate a new text file. The last target, baz
, specifies the other two targets as its dependencies. So when we run make baz
we should see no output (as we’ve used the special @
syntax to silence any output) but we should find we have the following files created:
- foo-file.txt
- bar-file.txt
- baz-file.txt
The last file in the list should contain not only a line that displays baz
but also two other lines comprising the contents of the other files. So running cat baz-file.txt
should print:
baz
foo
bar
Note: if you’ve not seen it used before, the
-
in thecat
command is telling it to expect input from stdin (theecho
command writes to stdout and that is piped|
over to thecat
command as stdin)
Accessing Targets
In the above example, I was generating a file based on the contents of two other targets (which themselves dynamically generated some files). There was a slight bit of repetition that could have been cleaned up if we used another automatic variable provided by Make, specifically $@
.
The $@
variable is a reference to the target name, so let’s see how we can use this with our previous example:
foo:
@echo $@ > "$@-file.txt"
bar:
@echo $@ > "$@-file.txt"
baz: foo bar
@echo $@ | cat - foo-file.txt bar-file.txt > "$@-file.txt"
In the example above we’ve saved ourselves from typing foo
, bar
and baz
a few times but we’ve not eradicated them completely as we still have to reference foo
and bar
as prerequisites, as well as referencing them from within the baz
command itself.
With regards to the baz
command, we could use $^
along with some shell scripting to clean that up so we’re again not relying on hardcoded values. The following example shows how to achieve that:
foo:
@echo $@ > "$@-file.txt"
bar:
@echo $@ > "$@-file.txt"
baz: foo bar
@files=$$(echo $^ | sed -E 's/([a-z]+)/\1-file.txt/g'); echo $@ | cat - $$files > "$@-file.txt"
Oh boy, OK. So yes, we’ve removed some more hardcoded values, but unless you’re supremely confident with shell scripting then I’m guessing the above refactor won’t make much sense to you. But let’s break it down a bit so we can see what we have:
- We use
$^
to get the list of dependencies; in this case,foo bar
. - We pipe that over to the
sed
command. We also use the extended regular expression engine-E
to make our regex pattern easier to understand. - The
sed
command replacesfoo bar
withfoo-file.txt bar-file.txt
. - We do that replacement within a subprocess
$()
, which is a special shell syntax. This means we have to escape the dollar sign within the Makefile ($$()
). - The values returned from the subprocess (
foo-file.txt bar-file.txt
) are then stored in a variable calledfiles
and we reference that variable in place of the original hardcoded values.
On top of all that, we still have duplication: the foo
and bar
referenced within the prerequisites area. That has to be hardcoded unless we’re going to use Make or some other form of shell scripting to dynamically generate the actual Makefile itself; which even for me is a step too far in this case.
OK, so what does this ultimately tell us? That simplicity is the key.
The reason I went to all this trouble is it allowed me to demonstrate first, how to really stretch what Make can do for you if you have enough shell scripting knowledge; and second, to allow me to now demonstrate how you can use more idiomatic Make to simplify the code and avoid overengineering like the previous example:
baz: foo-file.txt bar-file.txt
echo $@ | cat - $^ > $@-file.txt
%-file.txt:
echo $* > $@
In this refactored version we define a target called baz
and we set its dependencies to be two files that don’t exist. We also don’t have any defined targets in our Makefile either.
To solve this problem we use a virtual rule, one that uses Make’s %
placeholder syntax to pattern match against. We’ll see the %
syntax in more detail shortly, but for now it will suffice to know that it acts like a wildcard.
When we run make baz
, Make will try to resolve the two dependencies. The following rule %-file.txt
will then match both foo-file.txt
and bar-file.txt
and so the command echo $* > $@
will be executed twice.
The command takes the dynamic part of the rule (the foo
and bar
parts) and makes them available via $*
. We write those two values into $@
, which is the target name (in this case foo-file.txt
and bar-file.txt
) and subsequently create those two files.
We’ve now resolved the baz
rule’s dependencies and we can move on to executing its command, which completes the requirements as we’ve already seen.
Parsing Targets And Prerequisites
There are many different automatic variables available for Make and we’ll see a few more of them as we go along. But as we’ve already discussed $@
and $<
, it’s worth noting that you are also able to parse the specific directory and file name details for the first dependency and the target by using the syntax $(<D)
/$(<F)
for the prerequisite, and $(@D)
/$(@F)
for the target.
Using the following snippet as an example (you would run it with make foo/bar/baz.txt
):
bing/bop.txt:
@# do nothing
foo/bar/baz.txt: bing/bop.txt
@echo $(@D)
@echo $(@F)
@echo -------
@echo $(<D)
@echo $(<F)
The example above would output first the directory structure and then the file name which has been parsed from the target, and after that the directory structure and file name parsed from the prerequisite:
foo/bar
baz.txt
-------
bing
bop.txt
Depending on your requirements this can be quite a powerful tool to help you construct more complex commands.
Note: if you’re interested in knowing where your
make
binary is located then you can use the built-inMAKE
special variable in your command:@echo $(MAKE)
.
Dynamic Targets
Targets can dynamically match mulitiple unknown values and allow for abstracting away common functionality, such as generating files that have similar names (to give a simplified example).
To do this we need to take advantage of the placeholder syntax %
, and its corresponding $*
syntax. The following example demonstrates the basic structure:
dynamic-%:
@echo "Placeholder value: $* and target value: $@"
If you run the target using make dynamic-foo
then you’ll get the following response (notice that the dynamic aspect of the command foo
is captured in the placeholder):
Placeholder value: foo and target value: dynamic-foo
Dereferencing (Variables and Macros)
Make provides the multipurpose utility $()
, which is used to dereference values. The values can be functions (Make has many functions built in and we’ll take a quick glance at some of them later on) or they can be variable names. Let’s consider a simple example where we dereference a variable:
some_var := abc
print_var:
@echo $(some_var)
Notice in the above example that we defined the variable using the :=
syntax (whereas with most languages you would assign a value to a variable using =
). Make also supports =
as an alternative assignment operator but its use is specifically for situations where you need to take advantage of recursive dereferencing. Let’s see what that means in practice by reviewing the following example:
foo = $(bar)
bar = $(baz)
baz = qux value here
recursive:
@echo $(foo)
This returns qux value here
and demonstrates how the foo
variable recursively evaluated all other values thanks to the =
operator.
If we tried this using foo := $(bar)
instead, then the recursive
target would have printed out an empty line as it uses a straightforward simple expansion algorithm, which means its right-hand side value is expanded immediately (i.e. expanded at declaration time). With this example, Make doesn’t recursively expand the values back to bar
and subsequently back to baz
to find the final value of qux value here
.
There are also other types of assigment you can use, such as conditional variable ?=
. What that will do is assign a value to the defined variable only if it doesn’t already have a value defined. For example:
assignment = foo
assignment ?= bar
conditional_assignment:
@echo $(assignment)
If we run make conditional_assignment
, then we’ll see the value foo
printed. The value bar
isn’t assigned as a value was already defined.
One other assignment type worth considering is +=
, which works pretty much as you would expect it to if you’re a programmer (as it’s an operator that appears in many different languages). Effectively it appends the value onto the variable, keeping the original value as well. For example:
hello_world = hello
hello_world += world
say_hello:
@echo $(hello_world)
The example above prints hello world
, as it has appended world
onto the existing value hello
. Interestingly, Make automatically puts in a space as well between the values assigned (notice the value printed wasn’t helloworld
).
One last thing I want to cover is the use of macros in Make. A macro is a collection of commands that are expanded and executed when dereferenced. It’s a lot like a function, in that it groups behavior. The following example demonstrates how it works:
define do_lots_of_things
echo Hi there
echo I do lots of things
echo So it\'s best I do this in this macro
endef
stuff:
@$(do_lots_of_things)
When we execute make stuff
we see all the different messages printed to the screen. We could reuse this macro in many different target rules if we wanted to as well, which is really the whole point of them.
Note: notice that I had to escape the use of the single quote
'
. This was done because without it the command would fail due to a syntax error in Make.
Functions
As mentioned in the previous section, the $()
utility worked to dereference a value, but it can also handle a number of built-in functions. Although some of the functions could be replaced with standard shell commands.
Note: a full list of functions can be found on the GNU Make website.
Filter
Let’s take a look at some interesting functions Make provides. The first one I like the look of is filter
:
filter: foo.txt bar.txt baz.txt
@echo $(filter ba%.txt, $^)
In this rule we use the filter
function, which takes as its first argument the pattern you want to try to match and the text you want to search within. In our example the text to be searched is the list of prerequisites (using $^
which we’ve already seen). The pattern we’re hoping to match uses the %
placeholder wildcard value and the filter returns only files that begin with ba
and end in .txt
. This results in bar.txt baz.txt
that is printed.
Shell
Outside of a target you can have a variable dynamically pull data from the shell environment by using the v := $(shell <command>)
pattern.
Note: because we’re using the
shell
function, we use:=
for simple expansion rather than=
, which would allow for recursive dereferencing and could cause problems depending on what your Makefile and shell script is doing.
In the following example we use the shell
function to calculate the result of adding 1 and 1. We then dereference that value from within our target:
calculation := $(shell echo $$((1 + 1)))
shelled_value:
@echo $(calculation)
Note: in the shell, to do arithmetic (and other such things) we need to use the expression utility
$((...))
, so don’t make the mistake of thinking it’s a syntax special to Make, because it’s not.
Eval
In the following snippet we use the eval
function to create a Makefile variable dynamically at runtime:
dyn_eval:
$(eval FOOBAR:=$(shell echo 123))
@echo $(FOOBAR)
We use the shell
function to return a dynamically generated value (in this case 123
) and we assign that to a variable FOOBAR. But to allow us to access FOOBAR from other commands within this target, as well as other unrelated targets, we use eval
to create the variable globally. Finally, we use $()
to dereference the variable.
Files
The following technique allows us to carry out simple substitutions, by swapping the matched text before the =
with the text that follows it. The defined pattern is then applied to the variable being dereferenced:
files = foo.txt bar.txt baz.txt
change_ext:
@echo $(files:.txt=.doc)
The above example produces the following output (notice how the files
list of files now have .doc
extensions):
foo.doc bar.doc baz.doc
There are many functions and techniques to help you extend the capabilities within Make and so I would highly recommend you have a read through the functions listed in the GNU Make manual.
User-Defined Functions
You’ve already seen the use of macros via the syntax define
. User-defined functions work exactly the same way but you call them differently to macros (you’ll use the Make built-in call
function), and this is so that you can pass arguments to the definition. This is best demonstrated with an example:
define foo
@echo "I was called with the argument:$1"
endef
call_foo:
$(call foo, "hello!")
The example above would be executed with make call_foo
, and would result in the following output:
I was called with the argument: hello!
Note: earlier we noticed that Make would include a space when using the
+=
operator. The same happens with function arguments and so when creating the string that is printed I didn’t include a space after the:
but the output shows a space thanks to Make.
You can pass as many arguments as you like to a function and it’ll be accessible numerically (e.g. $1
, $2
, $3
and so on). You can also call other functions from within a function and pass on the arguments, or pass different arguments using the $(call function_name)
syntax.
Conventions
There are some well-known conventions and idioms used by the Make community, and a few of the most prominent ones are detailed in this section.
The first is the inclusion of a clean
target which should be used to remove any files created by your Makefile. This is to allow you to clean up after your tasks have executed (or if things have gone haywire). Typically the default target will specify clean
as a prerequisite so as to clear your workspace before starting a fresh build.
The second is to have a help
target which echo
s each of the targets within the file and explains its purpose. As demonstrated below:
help:
@echo foo: does foo stuff
@echo bar: does bar stuff
@echo baz: does baz stuff
Note: you could use some clever shell scripting along with Makefile comments to dynamically generate the printed commands and their descriptions (e.g. read in the Makefile source and parse out the meta data/comments as part of a sub shell
$(shell ...)
).
The third is to include a reference to a special target called .PHONY
at either the top or bottom of your Makefile, followed by a list of target names. The purpose of .PHONY
is to prevent conflicts with files within your current project directory that coincidentally match the name of your Makefile targets.
To clarify what this means in practical terms: Make has a convention whereby you would define a target’s name as matching the name of the file the commands will ultimately create; because although Make is useful for general purpose tasks, it was originally designed for creating application files. Make will associate a target with any file that matches its name and will intelligently monitor the dependencies for the target to see if it’s OK to re-execute the target’s command to regenerate the file.
Typically a target such as clean
won’t have any dependencies (not all the time mind you, but most of the time it won’t because the purpose of clean
is to remove generated files; it shouldn’t depend on any other files in order to complete that action). If a target has no dependencies then Make will always run the associated commands. Remember, Make can intelligently avoid running certain commands if it knows the dependencies haven’t changed at all.
By specifying clean
as being a “phony” target, it means if there was ever a file called clean
added to your project then we could avoid confusion as to how Make should handle running the target. The following demonstrates how it is used. It assumes you have a file – with no file extension – called clean
in your main project directory:
.PHONY: clean
clean:
@echo "I'll do something like remove all files"
In the above example, running make clean
will display the message “I’ll do something like remove all files”. But if you remove the .PHONY: clean
and rerun the target (using make clean
) you’ll now find, because we have a clean
file in our main project directory and no dependencies for that target, that Make will mistakenly think there is nothing left to do and so it displays the message:
make: 'clean' is up to date.
Note: like with automatic variables, there are many different special targets (so far we’ve seen
.PHONY
and.SILENT
). One that’s worth further investigation is.DELETE_ON_ERROR
, which indicates to Make that if any of the commands for your target rule fails then it should delete the associated target file in your project. A list of special targets is available on the GNU Make website.
Revisiting The For Loop Example
Earlier on we looked at a way of using a for loop as a command to loop over a list of text files and to print their names.
Let’s now consider two alternative ways of achieving this. The first uses a few more Make functions, while the second is more readable – but ultimately they use similar solutions.
Here is the first alternative:
my_list = $(addsuffix .dep, $(wildcard *.txt))
print_list: $(my_list)
%.dep: %
@echo "Text File:" $<
- The first thing we do is use the
wildcard
function to retrieve a list of text files (this is equivalent to$(shell ls *.txt)
). - We then use the
addsuffix
function to convert something likefoo.txt
intofoo.txt.dep
. This doesn’t actually create any files, by the way; you’ll see why we do this in a moment. - Next we create a target called
print_list
and we set its dependencies to be themy_list
list of file names (e.g.foo.txt.dep bar.txt.dep baz.txt.dep
). But obviously there are no such targets defined in our Makefile so this leads us to the next step. - We dynamically create targets that would match what’s found in
my_list
using a placeholder, and we set the dependency for these dynamic targets to be the text file itself. Remember that the target%.dep
would matchfoo.txt.dep
and so subsequently setting the dependency to just%
would be the valuefoo.txt
. - From here we can now echo the file name using
$<
, which gives us the first dependency in the list (of which we only have one anyway).
Now here is the second alternative:
my_list = $(wildcard *.txt)
print_list: $(my_list)
.PHONY: $(my_list)
$(my_list):
@echo "Text File:" $@
Again, let’s take a moment to break this down so we understand how it works:
- Like the first alternative, we retrieve the list of files using the
wildcard
function. The difference now is that we don’t need to create a copy of the list and modify the names. - Next we create a target called
print_list
and we set its dependencies to be themy_list
list of file names (e.g.foo.txt bar.txt baz.txt
). As we mentioned before, there are no such targets defined in our Makefile. - The next step is to define a
.PHONY
target. We do this because in the subsequent step we define a virtual rule, but we don’t specify any prerequisites. This means as we have actual files in our directory that match the potential target name, the rule will never be executed unless we specify it as being.PHONY
. - Now we define our virtual rule and we use the
$@
to print the name of the file when we executemake print_list
.
Includes
Make allows you to import more Make specific-functionality via its include
statement. If you create a file with a .mk
extension then that file’s Make related code can be included in your running Makefile. The following example demonstrates how it works:
include foo.mk # assuming you have a foo.mk file in your project directory
included_stuff:
@echo $(my_included_foo)
The above example relies on a foo.mk
file containing the following Make contents:
my_included_foo := hi from the foo include
When we run make included_stuff
, we see hi from the foo include
printed out.
Note: the
include
statement can also be written with a hyphen prefix like so-include
, which means if there is an error loading the specified file then that error is ignored.
Conclusion
We’ve barely even scratched the surface of what’s possible using Make, but hopefully this introduction has piqued your interest in learning more by either reading the GNU Make manual or picking up a book on the subject. I am myself only beginning my investigation into replacing my existing build tools with Make.
It’s been part of my journey to rediscover original Unix tools that have stood the test of time (for good reason) rather than picking up the new shiny thing which is often nothing more than a slightly modernized abstraction built for people who want to avoid the terminal/shell environment – somewhere I’ve become much more comfortable working the past couple of years.
But before we wrap up... time (once again) for some self-promotion 🙊