Learning Makefiles as a Java developer. Part 1: targets, prerequisites and recipes

Marcio Endo
December 11, 2023

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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.