February 15, 2024

lealceldeiro

The final keyword in Java might seem simple at first glance, but it’s a powerful tool that can significantly impact our code’s design, readability, and performance. In this article, we’ll delve into the different ways we can use final and explore its various benefits for Java developers.

What’s the final keyword in Java?

To put it simple, we say something is final when it can’t be "modified".

Imagine a chick that just hatched out of an egg. That egg is broken, and nothing can revert that; nobody can put together a hatched, or broken, egg, and make the small chick live inside it again, right? It’s the same in Java when something is final. However, technically speaking, the meaning that something can’t be modified takes several forms.

For example, we can use it when we have a variable holding a reference to an object and we want that variable to keep the same reference throughout the whole program execution. This means no other reference can be assigned to it.

But it can get tricky sometimes. Let’s see it in details.

Use cases for the final keyword

The final keyword can be applied in several scenarios. Let’s analyze them, one by one.

Final classes

To declare a class final we use the final class modifier. For example:

final class Payment {
}

A final class can’t be extended. This means no other class can be a subclass of the final class.

This is useful in situations where we want to limit the inheritance of our type (class), for example, for security reasons. It enforces the intended design and prevents unintended modifications through inheritance.

Final fields

To declare a field final, again we use the same keyword. For example, if we were to add a final field ("amount") to our previous class, this could be it:

final class Payment {
    private final BigDecimal amount;

    Payment(BigDecimal amount) {
        this.amount = amount;
    }
}

From now on, this field (amount) can’t be assigned a new value, or a compile-time error will be reported and the code will fail to compile.

final fields could still be modified through other mechanisms such as reflection (§17.5.3), but that’s a more complex topic, better covered in its own article.

It’s also important to have in mind that a final field (or variable, for the matter) holds a reference to an object, and that object can still be mutable regardless. So, it’s possible to mutate the state of the object.

For example, suppose we have a field like this: final Map<String, String> map = new HashMap<>(). If we call map.add("a", "b") we didn’t change the reference which map points to, but we effectively changed the value of map by adding a new entry.

In the previous Payment class example, notice, how we had to declare our own constructor to accept a value for our amount field. This is because all final instance fields must be initialized when the class instance is created. In the case of the static ones, they must be initialized when the class is loaded.

Other ways to initialize final fields are as follows:

  1. On the same line where the field is declared. For example:

    final class Payment {
        private static final BigDecimal MAX_ALLOWED = new BigDecimal(1000_000);
        private final BigDecimal amount = new BigDecimal(100);
    }
  2. Inside an instance, or static initializer, respectively. For example:

    final class Payment {
        private static final BigDecimal MAX_ALLOWED;
        private final BigDecimal amount;
    
        static {
            MAX_ALLOWED = new BigDecimal(1000_000);
        }
        {
            amount = new BigDecimal(100);
        }
    }
  3. For the sake of completeness, let’s mention here the third option: inside the class constructor (as we saw previously).

Final fields give us the big benefit of thread safety (§17.5). This doesn’t mean that if we declare all fields as final in a class that will make the class thread safe; but it’s one of the actions we can take towards that goal, and it’s a simple one.

It brings also with it more predictable and easy-to-reason-about code and the JVM can also perform low-level optimization over those fields because of their final nature.

One common scenario where final fields are used is when combined with the static keyword to create inmutable constants that we can use throughout our code.

Final methods

To declare a final method we use the same final keyword. For example:

class Payment {
    private final BigDecimal amount = new BigDecimal(100);

    final BigDecimal getAmount() {
        return amount;
    }
}

In this previous example the Payment class is not final. This means it can be subclassed and its methods overridden or hidden in the children classes (§8.4.3.3). One way to avoid that is to declare those methods as final.

This is a powerful technique: it permits that we have a class that can be extended but certain public behaviors of it can’t be changed, not even by its subclasses. This keeps the parent class maintainers in control, and avoid unexpected bugs.

Final variables

Final variables are also declared by using the same final keyword. For example, in the following method, the argument id and the local variable transaction are final.

class Payment {
    void executeTransaction(final Long id) {
        final Transaction transaction = getCurrentTransaction();
        // rest of the code omitted for brevity
    }
}

Something to note here is that if we declare a variable without the final keyword, and it’s never modified again, then it’s effectively final.

The use cases for final variables range from code standards and best practices established in some companies, to language requirements such as lambda expressions. Let’s analyze this last one.

Lambda expressions require that any local variable, formal parameter, or exception parameter used but not declared in a lambda expression must either be final or effectively final (§15.27.2).

Let’s take as an example the following code:

class Payment {
    void executeTransaction(Long id) {
        Transaction transaction = getTransaction();
        queueTasks(() -> System.out.printf("Queued tasks for transaction %s and id %s", transaction, id));
    }

    Transaction getTransaction() { return new Transaction(); }

    void queueTasks(Runnable callback) {
        // code omitted for brevity
        callback.run();
    }
}
class Transaction {}

For the sake of simplicity, let’s focus only on the method executeTransaction (and ignore the auxiliary methods).

void executeTransaction(Long id) {
    Transaction transaction = getTransaction();
    queueTasks(() -> System.out.printf("Queued tasks for transaction %s and id %s", transaction, id));
}

In its body, there’s a call to queueTasks which accepts a Runnable parameter. Because Runnable is a functional interface we can provide the argument as a lambda expression.

In the body of the lambda expression the variable transaction and the parameter id are used without being declared final explicitly. This is possible because they are effectively final.

If we were to re-assign a new value to either transaction or id then they would stop being effectively final. This would cause a compile-time error reported when either of them is first accessed inside the lambda body. The message would say something like Variable used in lambda expression should be final or effectively final.

To avoid such error, we can declare them as final and, if we ever re-assign a new value to them, we get a compile-time error on the line where the re-assignment happens.

Declaring final variables is also useful in scenarios where there are many variables declared (common in long methods from legacy code), and we want to make sure none of them is re-assigned a new value.

Final thoughts

Now that we’ve explored what’s the final keyword used for and how to apply it, it’s time for you to put that knowledge to work on your daily job.

Look again that class design and ask yourself: Is this class supposed to be subclassed? If yes, then document it, if not, then make it final. Your future self will thank you. You can always come back and make the class non-final, but you can’t make a class final without possibly breaking other code that could have extended your class (§13.4.2.3).

Questions like this should pop out of your mind when you design and implement your solutions. It will help with the overall code quality and the quality of the final product, which means happier final users.

One final word of caution.

With great power comes great responsibility. While final is powerful, its overuse can lead to inflexible code. We should use it judiciously, considering the trade-offs between immutability and flexibility and striving to achieve the correct balance.