Learning Makefiles as a Java developer. Part 2: phony targets, default goal and variables

Marcio Endo
December 17, 2023

edit 2023-12-24: add a link to part 3 of the series

In the first post we've learned a few of the basic concepts of Make. To do so, we've created a Java project containing a single Java file. I understand it does not represent the majority of "real world" Java projects.

Before we can move on to building "production-like" Java projects, I feel it is best if we introduce a few other Make concepts:

They are usually used all over in a Makefile. So we should know them.

Let's continue.

Iteration 05: phony targets

To recap, this is how we have been invoking make to build the JAR file of our project:

$ make work/library.jar

Which is quite verbose.

Adding a jar target

Let's improve on that. We would be easier if we could just say:

$ make jar

We can actually do that. Let's modify our Makefile and add the following target:

jar: work/library.jar

And let's remove all of the generated files:

$ rm -r work

And if we invoke Make:

$ make jar
javac -d work/main main/objectos/library/Say.java
jar --create --file=work/library.jar -C work/main .

It is an improvement.

Adding a clean target

We could also use a clean target as we have been doing the following quite a lot:

$ rm -rf work

Let's add it to our Makefile:

clean:
	rm -rf work

And if we invoke Make:

$ make clean
rm -rf work

Nice. There's a catch though.

Targets are files (usually)

As we have seen in the previous post, targets are files.

So, in the current form, we are asking Make to build files named jar and clean.

To understand, let's:

$ make clean
rm -rf work
$ make clean
rm -rf work
$ touch clean
$ make clean
make: 'clean' is up to date.

Once again, in the current form, we are asking Make to build a file named clean. But the rule we have declared does not create such file because that's not our intention. Once we manually create the clean file, make does not execute the rule anymore because the file:

While creating the file named clean is an unlikely event, we would like for the clean target to always run.

Marking clean and jar as phony

To handle cases like the clean target, Make has the concept of phony targets.

Let's alter our Makefile once again and mark both targets as phony. The full Makefile after the modification is listed below:

.PHONY: clean
clean:
	rm -rf work

work/main/objectos/library/Say.class: main/objectos/library/Say.java
	javac -d work/main main/objectos/library/Say.java

.PHONY: jar
jar: work/library.jar

work/library.jar: work/main/objectos/library/Say.class
	jar --create --file=work/library.jar -C work/main .

Notice the two declarations of the .PHONY special built-in target name

Now if we invoke clean target once again:

$ make clean
rm -rf work

It works. Even when the clean file still exists.

Phony targets

So a phony target is:

one that is not really the name of a file; rather it is just a name for a recipe to be executed when you make an explicit request.

I should have mentioned earlier, but the GNU Make manual is a great (if not the best) resource for learning Make. The quote was taken from the manual.

Iteration 06: the default goal

In iteration #02 we invoked make without any targets.

Let's see what happens if we try now:

$ make
rm -rf work

From the output, it seems make executed the clean target created in the previous section.

When a target is not specified in the command line, make will execute the first target it finds in the Makefile. This is called the default goal.

In general, Makefiles are written so that the default goal builds the entire project.

So let's turn the jar target into our default goal. Our Makefile becomes:

.PHONY: jar
jar: work/library.jar

.PHONY: clean
clean:
	rm -rf work

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 .

We moved the jar phony target to the top of our Makefile.

And let's invoke make:

$ make
javac -d work/main main/objectos/library/Say.java
jar --create --file=work/library.jar -C work/main .

It built the JAR file.

Iteration 07: automatic variables

In recipes, it is usual to refer to:

Make allows you to refer to them via automatic variables.

Target of the rule

The $@ automatic variable contains the value of the file name of the target of the rule.

So we can convert the target of our JAR file:

work/library.jar: work/main/objectos/library/Say.class
	jar --create --file=work/library.jar -C work/main .

To the following:

work/library.jar: work/main/objectos/library/Say.class
	jar --create --file=$@ -C work/main .

Notice the $@ variable in the --file option of the jar command.

First prerequisite

The $< automatic variable contains the value of the name of the first prerequisite.

So our compilation target:

work/main/objectos/library/Say.class: main/objectos/library/Say.java
	javac -d work/main main/objectos/library/Say.java

Becomes:

work/main/objectos/library/Say.class: main/objectos/library/Say.java
	javac -d work/main $<

Notice the $< variable as the file argument of the javac command.

Automatic variables in action

So our Makefile is now:

.PHONY: jar
jar: work/library.jar

.PHONY: clean
clean:
	rm -rf work

work/main/objectos/library/Say.class: main/objectos/library/Say.java
	javac -d work/main $<

work/library.jar: work/main/objectos/library/Say.class
	jar --create --file=$@ -C work/main .

Let's test it:

$ make clean
rm -rf work
$ make
javac -d work/main main/objectos/library/Say.java
jar --create --file=work/library.jar -C work/main .

It works as expected.

Iteration 08: variables

Automatic variables are a special kind of variables; meaning make also has the "regular" kind of variable.

Let's see variables, the regular ones, in action. We will use them to organize our Makefile.

Organizing the clean section

Let's start with the clean section of our Makefile:

#
# clean
#

## work directory (all generated files go here)
WORK = work

.PHONY: clean
clean:
    rm -rf $(WORK)

So, in a Makefile you declare and assign a value using the following:

WORK = work

The left hand side contains the name of the variable. The right hand side is the value assigned to the variable.

To use the variable reference you enclose the name with $(..) like so:

clean:
    rm -rf $(WORK)

With the WORK variable declared, it will be easier if someone prefers to use a Maven style directory structure. You just change the variable value to:

WORK = target

Additionally, we have also added a number of comments to our Makefile. Comments are all lines that start with a '#' character.

Organizing the compile section

Next, let's organize the compilation section of our Makefile:

#
# compile
#

## directory for the (main) compiled class files
CLASS_OUTPUT = $(WORK)/main

## javac command options
JAVACX = javac
JAVACX += -d $(CLASS_OUTPUT)
JAVACX += $<

work/main/objectos/library/Say.class: main/objectos/library/Say.java
    $(JAVACX)

Notice the following:

Organizing the package section

Finally, let's organize the JAR building (or packaging) section of our Makefile:

#
# package
#

## JAR file name
JAR_FILE = $(WORK)/library.jar

## JAR command
JARX = jar
JARX += --create
JARX += --file=$(JAR_FILE)
JARX += -C $(CLASS_OUTPUT)
JARX += .

.PHONY: jar
jar: $(JAR_FILE)

$(JAR_FILE): work/main/objectos/library/Say.class
    $(JARX)

Notice the following:

Putting it all together

The full version of our Makefile becomes:

#
# all: default goal
#

.PHONY: all
all: jar

#
# clean
#

## work directory (all generated files go here)
WORK = work

.PHONY: clean
clean:
    rm -rf $(WORK)

#
# compile
#

## directory for the (main) compiled class files
CLASS_OUTPUT = $(WORK)/main

## javac command options
JAVACX = javac
JAVACX += -d $(CLASS_OUTPUT)
JAVACX += $<

work/main/objectos/library/Say.class: main/objectos/library/Say.java
    $(JAVACX)

#
# package
#

## JAR file name
JAR_FILE = $(WORK)/library.jar

## JAR command
JARX = jar
JARX += --create
JARX += --file=$(JAR_FILE)
JARX += -C $(CLASS_OUTPUT)
JARX += .

.PHONY: jar
jar: $(JAR_FILE)

$(JAR_FILE): work/main/objectos/library/Say.class
    $(JARX)

We have added the all target to be our default goal. This was required because we moved the jar section to the top of our Makefile. We could have also used the .DEFAULT_GOAL special variable.

Additionally, it is no accident that the sections names are the same as names of phases of the Maven build lifecycle.

Let's make sure our build is working properly:

$ make clean
rm -rf work
$ make
javac -d work/main main/objectos/library/Say.java
jar --create --file=work/library.jar -C work/main .

It works!

Conclusion

In this blog post we have expanded on a few more basic concepts of Make.

Some may seem like nice to have features. They are more than that though. In particular:

We will see pattern rules in a future post of this series.

You can find the source code of the examples in this GitHub repository.

Continue reading

The third part of this series is already available.

You can continue reading by following this link.