Building Systems With Make

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 the cat command is telling it to expect input from stdin (the echo command writes to stdout and that is piped | over to the cat 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 replaces foo bar with foo-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 called files 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-in MAKE 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 echos 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 like foo.txt into foo.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 the my_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 match foo.txt.dep and so subsequently setting the dependency to just % would be the value foo.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 the my_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 execute make 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.