Learning Makefiles as a Java developer. Part 1: targets, prerequisites and recipes
edit 2023-12-17: add a link to the examples source code
I once asked myself: is it possible to make a build system that bootstraps itself via JEP 330?
In other words, is it possible to build a project like so:
$ java Bootstrap.java ActualBuild.java
I did not go forward with the idea. But the itch remained; I was looking for an alternative way to build Java projects.
A few months ago, I started experimenting with Makefiles to build a number of the Objectos' projects. I am very happy with the results so far.
So, in this series of blog posts, I will try and share what I have learned.
Let's begin.
Introduction
Before we begin, a few notes.
No prior knowledge of Make required
I had zero knowledge of make
before I started my experiments a few months ago.
Therefore, this series assumes this is the first time you are working with Make and Makefiles.
Writing style
In this series we will produce:
-
a Java project; and
-
the Makefile to build it.
We will do it in a series of small iterations.
It will not be as detailed as a tutorial. But you will be able to code along if you so desire.
Iteration 01: our Java project
Our project will evolve during this blog series.
As a starting point we will build a Java library. A hypothetical application would use our library like so:
import objectos.library.Say;
public class Application {
public static void main() {
Say.hello("world");
}
}
When executed, we expect the application to print:
Hello world!
Additionally, the application will consume the library as a JAR file.
So our library must provide:
-
the
objectos.library.Say
class; and -
a JAR file so applications can consume it.
Working directory
Let's create a working directory for our project:
$ mkdir makejava
$ cd makejava
$ pwd
/home/mendo/makejava
Any non-absolute path name given from this point forward will be relative to this directory.
The Say
class source file
Here's a version for the Say
class:
package objectos.library;
public class Say {
public static void hello(String who) {
System.out.println("Hello " + who + "!");
}
}
Let's create it as main/objectos/library/Say.java
:
$ mkdir --parents main/objectos/library
$ vi main/objectos/library/Say.java
Of course, you may use any editor. The last line of the listing is mostly to show the path name of the Java file.
Next, let's compile it.
Compiling the Say
class
To compile our class, we need the javac
tool:
$ javac --version
javac 21.0.1
Let's "manually" compile our class:
$ javac -d work/main main/objectos/library/Say.java
The -d
option specifies where the compiler should write the generated .class
files:
$ find -type f
./main/objectos/library/Say.java
./work/main/objectos/library/Say.class
Next, let's package our class in a JAR file
The library.jar
JAR file
To create our JAR file, we need the jar
tool:
$ jar --version
jar 21.0.1
And we issue the following command:
$ jar --create --file=work/library.jar -C work/main .
Which should:
-
create a JAR file;
-
named
library.jar
located at thework
directory; and -
containing everything under the
work/main
directory.
Let's make sure we did it right:
$ jar --list --file=work/library.jar
META-INF/
META-INF/MANIFEST.MF
objectos/
objectos/library/
objectos/library/Say.class
It seem OK.
Iteration 02: a Makefile rule primer
We have confirmed that we can "manually" create the JAR file of our project.
Next, let's try to perform the same tasks using the make
tool.
Removing the generated files
Let's remove all of the generated files in the previous section:
$ rm -r work
GNU Make
Let's make sure we have the make
tool installed:
$ make --version
GNU Make 4.4.1
Built for x86_64-pc-linux-gnu
Copyright (C) 1988-2023 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <https://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
We have been referring to the tool as make
.
But it is important to know that we will be talking about GNU Make in this series.
To be honest, I don't know the differences between GNU Make to other make flavors. I have not tried other Make implementations other than GNU's. All I know is that there are differences.
So, to be sure, when we say Make we mean GNU Make specifically.
And when we say make
we are referring to the GNU Make executable.
Invoking make
Let's try to execute make
with our project.
We haven't created any Make related files so we should expect some sort of error message.
Let's see:
$ make
make: *** No targets specified and no makefile found. Stop.
Indeed we got a message.
From the output we can infer that make
requires:
-
a Makefile; and
-
a target.
Let's continue.
The make tool, well, "makes stuff"
So make
requires you to give it a target.
In other words, make
requires you to say what should be made.
We want to make a JAR file of our project.
We want it to be generated in the work
directory.
So we should tell make
that's what we want to build:
$ make work/library.jar
make: *** No rule to make target 'work/library.jar'. Stop.
Nice, we got a different error message! Maybe we are moving in the right direction.
Make now says there's no rule to make our target.
It seems logical;
we haven't told make
how to produce our JAR file.
So we can infer that:
-
make
requires you to define how the target should be made; and -
the how definition is named rule in make jargon.
The Makefile
We know how to make the JAR file: we have determined the steps required in the previous iteration.
So let's express those steps in a Makefile:
$ vi Makefile
And we should add the following rule:
work/library.jar:
javac -d work/main main/objectos/library/Say.java
jar --create --file=work/library.jar -C work/main .
The rule starts with our target, namely the work/library.jar
file.
The target file name is followed by a colon character.
The next lines contain a sequence of shell commands.
Each line starts with a tab character.
These are to be run by the make
tool in order to "make" or "remake" the target.
They are collectively called the rule's recipe.
As we will see later, this solution is far from ideal. But, for now, let's continue and build our project.
Building our project
After creating the Makefile
file, we should have:
$ find -type f
./main/objectos/library/Say.java
./Makefile
Let's invoke make
:
$ make work/library.jar
javac -d work/main main/objectos/library/Say.java
jar --create --file=work/library.jar -C work/main .
We can see that make
printed out the shell commands it ran to make our JAR file.
These are the exact commands we defined in the rule from the previous section.
Let's verify if the commands were actually run:
$ find -type f
./main/objectos/library/Say.java
./Makefile
./work/main/objectos/library/Say.class
./work/library.jar
OK, we have the Say.class
file.
And let's check the contents of the JAR file:
$ jar --list --file=work/library.jar
META-INF/
META-INF/MANIFEST.MF
objectos/
objectos/library/
objectos/library/Say.class
Nice, it worked (it seems)!
Iteration 03: prerequisites
As mentioned, though the Makefile seems to be working, it is far from ideal in its current form.
Is it out of date?
Let's run the work/library.jar
target once again:
$ make work/library.jar
make: 'work/library.jar' is up to date.
So make
is telling us that it did not run any shell commands because the target "is up to date".
Interesting, make
seems to keep track if our JAR file is out of date or not.
Making a change to the Say
class
So what happens if we modify our Say.java
file?
Let's add another method to the Say
class:
public class Say {
public static void hello(String who) {
System.out.println("Hello " + who + "!");
}
public static void hi(String who) {
System.out.println("Hi " + who + "!");
}
}
We have modified the source file. Therefore, our JAR file currently contains an obsolete class file.
Let's run the work/library.jar
target once more:
$ make work/library.jar
make: 'work/library.jar' is up to date.
This is not correct: the JAR file is obsolete and it should have been rebuilt. Let's try to fix it.
Target's prerequisites
We must tell make
that it should rebuild our JAR file whenever there's a change in the source code.
One way to do it is to declare the Say.java
file as a prerequisite of our JAR target:
work/library.jar: main/objectos/library/Say.java
javac -d work/main main/objectos/library/Say.java
jar --create --file=work/library.jar -C work/main .
So in a Make rule, the target's filenames go before the colon.
After the colon, you write the target's prerequisites.
In other words, we're telling make
that the work/library.jar
file should be "made" or "remade" when:
-
it does not exist; or
-
its prerequisite, the
Say.java
file in this case, is more recent than it.
Let's run make
:
$ make work/library.jar
javac -d work/main main/objectos/library/Say.java
jar --create --file=work/library.jar -C work/main .
As the library.jar
file was older than the Say.java
file, make
executed the recipe again.
Let's invoke make
once more:
$ make work/library.jar
make: 'work/library.jar' is up to date.
Let's force a (pseudo)change to the Say.java
file and run make
again:
$ touch main/objectos/library/Say.java
$ make work/library.jar
javac -d work/main main/objectos/library/Say.java
jar --create --file=work/library.jar -C work/main .
Finally, let's delete the JAR file and it should be rebuilt from scratch:
$ rm work/library.jar
$ make work/library.jar
javac -d work/main main/objectos/library/Say.java
jar --create --file=work/library.jar -C work/main .
All right. It seems to be working correctly now.
Iteration 04: targets and prerequisites are files (usually)
Targets in a Makefile are usually files. There are times when they are not, as we will see shortly. But when we are building a project we want to produce one or more files. For example:
-
a JAR file containing the classes of your library or application;
-
a JAR file containing the javadocs of your library;
-
a bundle of your library to be published to Maven central;
-
an archive with the jlink runtime of your application; and
-
so on...
The prerequisites of a target are also files (usually). And these files also need to be built and rebuilt according to a rule. Which might have other prerequisites and so on.
So Make handles these relationships for you.
Splitting our rule
The recipe of our target currently does two tasks:
-
generate the class file by compiling the source code; and
-
generate the JAR file with the class file from the previous step.
So one could say that the JAR file depends indirectly on the source file. The JAR file depends directly on the class file.
JAR file depends on class files
So let's express the fact that the JAR file should be rebuilt when there's a new version of the class file.
We can say it like this:
work/library.jar: work/main/objectos/library/Say.class
jar --create --file=work/library.jar -C work/main .
Let's delete all of the generated files and run make
with this version of the Makefile:
$ rm -r work
$ make work/library.jar
make: *** No rule to make target 'work/main/objectos/library/Say.class', needed by 'work/library.jar'. Stop.
So we need a rule to produce the class file.
Class file depends on source file
So let's express the compilation rule:
work/main/objectos/library/Say.class: main/objectos/library/Say.java
javac -d work/main main/objectos/library/Say.java
So we are telling make
that:
-
the
Say.class
file should be made when it doesn't exist; -
the
Say.class
file should be remade when it is older than theSay.java
file; and -
to make or remake the
Say.class
file invoke the following shell command.
So our full Makefile is currently the following:
work/main/objectos/library/Say.class: main/objectos/library/Say.java
javac -d work/main main/objectos/library/Say.java
work/library.jar: work/main/objectos/library/Say.class
jar --create --file=work/library.jar -C work/main .
Let's run make
:
$ make work/library.jar
javac -d work/main main/objectos/library/Say.java
jar --create --file=work/library.jar -C work/main .
And we can also invoke each target separately:
$ rm -r work
$ make work/main/objectos/library/Say.class
javac -d work/main main/objectos/library/Say.java
$ make work/library.jar
jar --create --file=work/library.jar -C work/main .
So targets and prerequisites are usually files. But sometimes they are not as we will see in the next post of this series.
Conclusion
This was a short introduction to some of the basic concepts of Make:
-
make
is for making targets; -
make
requires you to declare a rule for each target (caveat); -
make
will remake a target if it is out of date; -
a target is out of date when it is older than its prerequisite;
-
to make a target
make
follows a recipe; -
a recipe is a sequence of shell commands; and
-
each line in a recipe must start with a tab character.
You can find the source code of the examples in this GitHub repository.
Continue reading
The second part of this series is already available.
You can continue reading by following this link.