Skip to content

4. Classes, Records, Interfaces

4.1. Objects by Example

4.1.1. Overview

We will now explore object-oriented programming through examples. An object is an entity that contains data defining its state (referred to as fields, attributes, etc.) and functions (referred to as methods). An object is created based on a template called a class. For example, a class named [C1] declares two fields, [Type1 p1;] and [Type2 p2;], and two methods, [Type3 m3(...) { ... }] and [Type4 m4(...) { ... }], all enclosed within the curly braces of the declaration [public class C1 { ... }].

From the class [C1], you can create many objects such as [o1], [o2], … All of them will have the fields [p1], [p2], … and the methods [m3], [m4], … However, they will have different values for their [pi] fields, thus each having its own unique state. If [o1] is an object of type [C1], [o1.p1] refers to the [p1] field of [o1], and [o1.m3(...)] refers to the call to the [m3] method of [o1].

Let’s consider an initial object model: the class [Person].

4.1.2. A program consisting of multiple files

In the examples from the previous chapter, a program fit into a single source file. From now on, a program can consist of multiple source files: one class per file, plus the main program. In Java, a public class must be placed in a file that bears its name: the public class [Person] is in the file [Person.java]. We store the files for a single program in a folder—in this case, the [Persons] folder within the [04-classes] folder:

1
2
3
Persons/
  Person.java       the Person class
  Main.java         the main program

Starting with Java 22, the source code launcher—which we already used in the previous chapter—can run a program consisting of multiple files: the command [java Main.java], entered in the [Persons] folder, compiles the file [Main.java] in memory, then, on demand, the other files in the folder that it needs—in this case, [Person.java]—and runs the program. No compiled files are written to disk. You can also explicitly compile the files using the [javac] compiler, which writes a [.class] file (intermediate code, or “bytecode”) per class, and then run the program with the command [java], specifying where to find the compiled classes (option [-cp], the “classpath”):

1
2
3
4
5
6
> javac -d classes *.java
> ls classes
Main.class
Person.class
> java -cp classes Main
[Jean, Dupont, 30]

The multi-file launcher is sufficient for the programs in this chapter, which use only the classes from JDK. When a program requires external libraries, we will use a Maven project, as introduced in Chapter 2: Maven downloads the libraries, compiles the project, and runs it. The files in this chapter can be opened as-is in IntelliJ, IDEA, or VS Code.

4.1.3. Definition of the Person Class

The definition of the [Person] class in the source file [Person.java] will be as follows:

public class Person {
    // attributes (fields)
    private String firstName = "";
    private String lastName = "";
    private int age;

    // method
    public void initialize(String p, String n, int age) {
        this.firstName = p;
        this.lastName = n;
        this.age = age;
    }

    // method
    public void identify() {
        IO.println("[" + firstName + ", " + lastName + ", " + age + "]");
    }
}
  • Line 1: The class is public ([public]): it can be used by all other classes. It does not belong to any package—or, more precisely, it belongs to the unnamed package; packages are discussed at the end of the chapter;
  • Lines 3–5: The three fields of the class. Both strings are initialized with the empty string: otherwise, they would be set to [null] when the object is created (an uninitialized object field is set to [null], a numeric field to 0, and a Boolean field to [false]);
  • lines 8–12: a method that sets the object’s state;
  • lines 15–17: a method that displays the object’s state. [IO] is the Java 25 console I/O class we used in the previous chapter.

Here we have the definition of a class—that is, a data type. When we create variables of this type, we call them objects or class instances. A class is therefore a template from which objects are constructed. By convention, a class name begins with a capital letter ([Person]), while a field or method name begins with a lowercase letter ([firstName], [initialize]).

The members of a class can be data (fields) or methods (functions). These members can be accompanied by one of the following keywords:

Keyword
Access
[private]
A private member is accessible only by the class’s internal methods
(none)
If no keyword is specified, the member is accessible by any method in a class from the same package. This is the default access, known as “package access”
[protected]
A protected member is accessible by methods of classes in the same package and by methods of derived classes (see the concept of inheritance below)
[public]
A public member is accessible by any method, whether or not it is defined within the class

In general, a class’s data is declared private, while its methods are declared public. This means that the user of an object (the programmer):

  • will not have direct access to the object’s private data;
  • can call the object’s public methods, including those that provide access to its private data.

The order in which the members [private], [protected], and [public] are declared is arbitrary. Note that the default access is not private: a field declared without a keyword is visible to all classes in the package. Therefore, you must remember to write [private].

4.1.4. The initialize method

What is the role of the method [initialize]? Because [lastName], [firstName], and [age] are private members of the [Person] class, a statement such as [p1.firstName = "Jean";], written in the main program, is invalid. The compiler rejects it:

1
2
3
4
5
6
> java Main.java
Main.java:4: error: firstName has private access in Person
    p1.firstName = "Jean";
      ^
1 error
error: compilation failed

The compiler message is in English: JDK has no French translation for its messages. It specifies the file and line number of the error, then marks it with a circumflex accent. We need to initialize an object of type [Person] via a public method. This is the role of the [initialize] method. We will write [p1.initialize("Jean", "Dupont", 30);]. This syntax is valid because [initialize] is publicly accessible.

4.1.5. The new operator

However, something is still missing. The declaration [Person p1;] declares [p1] as a reference to an object of type [Person]. This object does not yet exist, and therefore [p1] is not initialized. When we then write [p1.initialize("Jean", "Dupont", 30);], we call the method [initialize] of the object referenced by [p1]. However, this object does not exist, and the compiler reports the error: the local variable [p1] has not been assigned a value.

1
2
3
4
5
6
> java Main.java
Main.java:4: error: variable p1 might not have been initialized
    p1.initialize("Jean", "Dupont", 30);
    ^
1 error
error: compilation failed

If we write [Person p1 = null;] followed by [p1.initialize("Jean", "Dupont", 30);], we explicitly indicate with the keyword [null] that the variable [p1] does not yet reference any object. The compiler accepts the program, but at runtime, the method call triggers a [NullPointerException] exception:

1
2
3
> java Main.java
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "Person.initialize(String, String, int)" because "<local1>" is null
    at Main.main(Main.java:4)

Starting with Java 14, the exception message specifies which operation failed; [<local1>] refers to the local variable [p1], whose name was not preserved by the in-memory compilation. For [p1] to reference an object, you must write [Person p1 = new Person();].

This creates an object of type [Person] that has not yet been initialized: its fields have their default values—in this case, the empty string for [lastName] and [firstName], and 0 for [age]. Now that [p1] references an object, the initialization statement for this object, [p1.initialize("Jean", "Dupont", 30);], is valid.

4.1.6. The this keyword

Let’s look at the code for the [initialize] method:

    public void initialize(String p, String n, int age) {
        this.firstName = p;
        this.lastName = n;
        this.age = age;
    }

The statement [this.firstName = p;] means that the field [firstName] of the current object ([this]) is assigned the value [p]. The keyword [this] refers to the current object: the one on which the method is executed. How do we know this? Let’s look at how the object referenced by [p1] is initialized in the calling program: [p1.initialize("Jean", "Dupont", 30);]. It is the method [initialize] of the object [p1] that is called. When, within this method, the object [this] is referenced, it actually refers to the object [p1].

When a method of an object references a field [a] of that object, the notation [this.a] is implied: the method [initialize] could have written [firstName = p;] and [lastName = n;]. You must use [this] explicitly when there is a conflict of identifiers. This is the case with the statement [this.age = age;] (line 11), where [age] refers to both a field of the current object and the parameter [age] received by the method. In the method, the name [age] alone refers to the parameter; the field must be referred to as [this.age].

4.1.7. A Test Program

Here is a short test program. It is written in the source file [Main.java]:

1
2
3
4
5
6
7
// the main program
void main() {
    // a Person object
    Person p1 = new Person();
    p1.initialize("Jean", "Dupont", 30);
    p1.identify();
}
  • Line 2: The method [main] is the program’s entry point. As in the previous chapter, it is written without an enclosing class: the file [Main.java] is a compact source file (see the following paragraph);
  • line 4: creation of a [Person] object referenced by [p1]. Since the [Person] class belongs to the same package (the unnamed package), it is recognized without an import statement;
  • Line 5: Initialization of the object;
  • line 6: display of its status.

Results:

> java Main.java
[Jean, Dupont, 30]

4.1.8. Compact source files

For a short example, a folder containing multiple files is unnecessarily large. As in the previous chapter, we will therefore write a compact source file (Java 25): a file that contains the method [void main()], possibly other methods and fields, and the classes the program needs. We run it with the source code launcher: [java Classes01.java].

It is important to understand how the compiler processes such a file. It wraps it in an implicit class, which bears the file’s name ([Classes01]): the file’s methods and fields become members of this class, and the classes declared in the file become nested classes of this implicit class. The explicit compilation of the file demonstrates this:

1
2
3
4
> javac -d classes Classes01.java
> ls classes
Classes01$Person.class
Classes01.class

The full name of the class [Person] in the file [Classes01.java] is therefore [Classes01$Person]. This has two consequences that we will encounter in the examples:

  • a class and the classes it contains share their private members: the [main] method in a compact file has access to the private fields of the classes in that file. To ensure that a private field is inaccessible from outside its class, you must, as in the [Persons] folder, place the class in its own file;
  • A regular nested class is an internal class: its objects are bound to an object of the enclosing class. This is not apparent as long as the objects are created by methods in the file, but a static method (see the section on class members) cannot create them. We then declare the nested class [static]. Records and interfaces, discussed later, are always static.

Finally, a compact file automatically imports all public classes from the [java.base] module: [List], [ArrayList], [BigDecimal], [Objects]… can be used there without the [import] clause. This is not the case for a regular file such as [Person.java], as we will see.

4.1.9. Another initialize method: overloading

Let’s continue to consider the class [Person] and add a second method to it: [initialize]. The program [Classes01.java] is as follows:

// overloading of the initialize method
void main() {
    Person p1 = new Person();
    p1.initialize("Jean", "Dupont", 30);
    p1.identify();
    Person p2 = new Person();
    p2.initialize(p1);
    p2.identify();
}

// the Person class is declared after the main method
class Person {
    // attributes
    private String firstName = "";
    private String lastName = "";
    private int age;

    // initialization from three values
    public void initialize(String p, String n, int age) {
        firstName = p;
        lastName = n;
        this.age = age;
    }

    // initialization from another person
    public void initialize(Person p) {
        firstName = p.firstName;
        lastName = p.lastName;
        age = p.age;
    }

    // identity of the person
    public void identify() {
        IO.println("[" + firstName + ", " + lastName + ", " + age + "]");
    }
}
  • lines 2–9: the main program;
  • line 12: the class [Person] is declared after the method [main]. It is not public: in a compact file, this is unnecessary;
  • lines 19–23: the [initialize] method from the previous paragraph;
  • lines 26–30: a second method, [initialize].

We now have two methods named [initialize]: this is valid as long as they accept different parameters (in number or type). The method [initialize] is said to be overloaded. The compiler chooses which method to call based on the actual parameters: [p1.initialize("Jean", "Dupont", 30)] (line 4) calls the first one, and [p2.initialize(p1)] (line 7) calls the second one. The return type alone is not sufficient to distinguish between two methods: two methods with the same name and the same parameters that differ only in their return type would be rejected.

The parameter of the second method is a reference ([p]) to a person. The fields of the person ([p]) are then assigned to the current object ([this]). Note that the method [initialize] has direct access to the fields of the object [p] even though they are private (lines 27–29). This is always true: a method of a class [C] has access to the private members of all objects of the same class [C].

Results:

1
2
3
> java Classes01.java
[Jean, Dupont, 30]
[Jean, Dupont, 30]

4.1.10. Constructors of the Person class

A constructor is a method that bears the name of the class and is called when the object is created. It is generally used to initialize the object. It is a method that can accept parameters but does not return a value. Its declaration is not preceded by any type (not even [void]).

If a class [C] has a constructor that accepts n parameters, the declaration and initialization of an object of this class can be done in the form [C object = new C(arg1, arg2, ... argn);], or in two steps: [C object;], followed later by [object = new C(arg1, arg2, ... argn);].

When a class [C] has one or more constructors, one of these constructors must be used to create an object of that class. If a class [C] has no constructors, it has a default constructor with no parameters that does nothing. The object’s fields then retain their initial values. This is what happened in the previous programs, where we had written [new Person()].

The program [Classes02.java] returns three constructors for class [Person]:

// constructors of the Person class
void main() {
    Person p1 = new Person("Jean", "Dupont", 30);
    p1.identify();
    Person p2 = new Person(p1);
    p2.identify();
    Person p3 = new Person();
    p3.identify();
}

class Person {
    // attributes
    private String firstName;
    private String lastName;
    private int age;

    // main constructor
    public Person(String firstName, String lastName, int age) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.age = age;
    }

    // copy constructor: it calls the main constructor
    public Person(Person p) {
        this(p.firstName, p.lastName, p.age);
    }

    // parameterless constructor: it also calls the main constructor
    public Person() {
        this("?", "?", 0);
    }

    // identity of the person
    public void identify() {
        IO.println("[" + firstName + ", " + lastName + ", " + age + "]");
    }
}
  • lines 18–22: the “main” constructor initializes the three fields. Since its parameters have the same names as the fields, the fields are distinguished using [this];
  • lines 25–27: the copy constructor. The statement [this(p.firstName, p.lastName, p.age);] calls the constructor of the same class, which accepts three parameters: [String], [String], and [int]. This is referred to as chained constructors. As a result, the initialization code is written only once;
  • lines 30–32: the parameterless constructor, which is also chained to the main constructor. Since the class has declared constructors, it no longer has a default constructor: if you want to be able to write [new Person()], you must declare it;
  • lines 13–15: the fields are no longer initialized at the time of their declaration; all constructors, either directly or through chaining, assign a value to them.

The call [this(...)] to another constructor can only be made within a constructor. Up to Java 24, it had to be the first statement; since Java 25, statements that do not use the object being constructed (to verify or prepare parameters, for example) may precede it. We could also have constructors call a method such as [initialize]; however, chaining is preferable: the initialization code is in a single location, and a constructor should not call a method that a derived class might override—we’ll see why.

Results:

1
2
3
4
> java Classes02.java
[Jean, Dupont, 30]
[Jean, Dupont, 30]
[?, ?, 0]

4.1.11. Object References

We use the [Person] class from the previous program, omitting its parameterless constructor but including the [initialize(String, String, int)] method from the beginning of the chapter. The test program becomes the following [Classes03.java]:

// object references
void main() {
    // p1
    Person p1 = new Person("Jean", "Dupont", 30);
    IO.print("p1="); p1.identify();
    // p2 references the same object as p1
    Person p2 = p1;
    IO.print("p2="); p2.identify();
    // p3 references an object that is a copy of the object referenced by p1
    Person p3 = new Person(p1);
    IO.print("p3="); p3.identify();
    // we change the state of the object referenced by p1
    p1.initialize("Micheline", "Benoît", 67);
    IO.print("p1="); p1.identify();
    // since p2 = p1, the object referenced by p2 has changed state
    IO.print("p2="); p2.identify();
    // p3 does not reference the same object as p1: the object referenced by p3 has not changed
    IO.print("p3="); p3.identify();
    // are p1 and p2 the same reference? and p1 and p3?
    IO.println("p1 == p2 : " + (p1 == p2) + ", p1 == p3 : " + (p1 == p3));
}

class Person {
    // attributes
    private String firstName;
    private String lastName;
    private int age;

    // constructors
    public Person(String firstName, String lastName, int age) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.age = age;
    }

    public Person(Person p) {
        this(p.firstName, p.lastName, p.age);
    }

    // initialization
    public void initialize(String p, String n, int age) {
        firstName = p;
        lastName = n;
        this.age = age;
    }

    // identity of the person
    public void identify() {
        IO.println("[" + firstName + ", " + lastName + ", " + age + "]");
    }
}

The [IO.print] method (lines 5, 8, …) writes its parameter without wrapping to the next line. Results:

1
2
3
4
5
6
7
8
> java Classes03.java
p1=[Jean, Dupont, 30]
p2=[Jean, Dupont, 30]
p3=[Jean, Dupont, 30]
p1=[Micheline, Benoît, 67]
p2=[Micheline, Benoît, 67]
p3=[Jean, Dupont, 30]
p1 == p2 : true, p1 == p3 : false

When the variable [p1] is declared via [Person p1 = new Person("Jean", "Dupont", 30);] (line 4), [p1] references the object [Person("Jean", "Dupont", 30)] but is not the object itself. In C, this would be referred to as a pointer—that is, the address of the created object. If we then write [p1 = null;], it is not the object [Person("Jean", "Dupont", 30)] that is modified; rather, it is the reference [p1] that changes its value. The object will be “lost” if it is not referenced by any other variable: the memory it occupies will later be reclaimed by the Java virtual machine’s garbage collector.

When we write [Person p2 = p1;] (line 7), we initialize the reference [p2]: it “points” to the same object (it refers to the same object) as the reference [p1]. Thus, if the object “pointed to” (or referenced) by [p1] is modified, the object referenced by [p2] is also modified (lines 5–6 of the results).

When [Person p3 = new Person(p1);] is written (line 10), a new object, [Person], is created. This new object will be referenced by [p3]. If you modify the object referenced by [p1], you do not modify the one referenced by [p3] (line 7 of the results).

Finally, on line 20, the operator [==], applied to two object references, indicates whether they refer to the same object: [p1] and [p2] refer to the same object, while [p1] and [p3] refer to two different objects, although they have the same content (line 8 of the results). We will see later how to compare the contents of two objects using the [equals] method.

4.1.12. Passing Object Reference Parameters

In the previous chapter, we saw that Java passes method parameters by value: the method receives a copy of the actual parameter’s value. Let’s see what this means when the parameter is an object reference. To do this, we’ll use the [StringBuilder] class from JDK. A [String] object cannot be modified: an operation such as [s.toUpperCase()] returns a new [String] object. A [StringBuilder] object, on the other hand, is a mutable string: its [append] method adds characters to the string without creating a new object.

The program [Classes04.java] is as follows:

// passing parameters of object reference type
void main() {
    StringBuilder sb0 = new StringBuilder("test0"), sb1 = new StringBuilder("test1");
    IO.println("In calling function before call: sb0=" + sb0 + ", sb1=" + sb1);
    StringBuilder sb2 = changeStringBuilder(sb0, sb1);
    IO.println("In calling function after call: sb0=" + sb0 + ", sb1=" + sb1 + ", sb2=" + sb2);
}

// the called function
StringBuilder changeStringBuilder(StringBuilder sbf0, StringBuilder sbf1) {
    IO.println("Start of called function: sbf0=" + sbf0 + ", sbf1=" + sbf1);
    // we modify the object referenced by sbf0
    sbf0.append("*****");
    // sbf1 now references a new object
    sbf1 = new StringBuilder("test1*****");
    // a new object is returned to the caller
    StringBuilder sbf2 = new StringBuilder("test2*****");
    IO.println("End of called function: sbf0=" + sbf0 + ", sbf1=" + sbf1 + ", sbf2=" + sbf2);
    return sbf2;
}
  • line 3: two references, [sb0] and [sb1], to objects of type [StringBuilder];
  • line 5: these are passed to the method [changeStringBuilder], whose result—a third reference—is assigned to [sb2];
  • lines 10–20: a method in the compact file, which has the formal parameters [sbf0] and [sbf1]. When the method starts, [sbf0] and [sb0] are two distinct references that point to the same object: the reference [sb0] was copied into [sbf0] (references are passed by value). The same applies to [sbf1] and [sb1].

Results:

1
2
3
4
5
> java Classes04.java
In calling function before call: sb0=test0, sb1=test1
Start of called function: sbf0=test0, sbf1=test1
End of called function: sbf0=test0*****, sbf1=test1*****, sbf2=test2*****
In calling function after call: sb0=test0*****, sb1=test1, sb2=test2*****

Explanations:

  • [sb0] and [sbf0] are two distinct references to the same object. This object was modified via [sbf0] (line 13). This modification is visible via [sb0] (line 5 of the results);
  • [sb1] and [sbf1] are two distinct references to the same object. [sbf1] is assigned a new value in the method and now points to a new object (line 15). This does not change the value of [sb1], which continues to point to the original, unmodified object;
  • a Java method cannot, therefore, change the value of a caller’s variable. To pass a new object back to the caller, it returns it as a result (lines 17 and 19). When there are multiple results, they are grouped into an object, such as a record (see below).

Keep in mind that a method can modify the object referenced by a parameter, but not the caller’s variable that contains the reference. Unlike other languages such as C#, Java does not support passing parameters by reference.

4.1.13. Temporary Objects

In an expression, you can explicitly call an object’s constructor: the object is created, but there is no variable to access it later (to modify it, for example). This temporary object is created for the purpose of evaluating the expression and is then discarded. The memory space it occupied will later be automatically reclaimed by the garbage collector, whose role is to reclaim memory space occupied by objects that are no longer referenced by the program’s data.

Consider the following program, [Classes05.java], where the constructors of the [Person] class display a message:

// temporary objects
void main() {
    new Person(new Person("Jean", "Dupont", 30)).identify();
}

class Person {
    // attributes
    private String firstName;
    private String lastName;
    private int age;

    // constructors
    public Person(String firstName, String lastName, int age) {
        IO.println("Constructor Person(String, String, int)");
        this.firstName = firstName;
        this.lastName = lastName;
        this.age = age;
    }

    public Person(Person p) {
        IO.println("Constructor Person(Person)");
        firstName = p.firstName;
        lastName = p.lastName;
        age = p.age;
    }

    // identity of the person
    public void identify() {
        IO.println("[" + firstName + ", " + lastName + ", " + age + "]");
    }
}
  • Line 3: The object [Person("Jean", *"Dupont", 30)]* is constructed and then passed to the copy constructor, which constructs a second object, whose method [identify] is called. Neither of the two objects is referenced by a variable.

Results:

1
2
3
4
> java Classes05.java
Constructor Person(String, String, int)
Constructor Person(Person)
[Jean, Dupont, 30]

The results show the successive creation of the two temporary objects.

4.1.14. Accessors and Mutators

We add the necessary methods to the [Person] class to read or modify the state of the fields in the [Classes06.java] objects:

// methods for reading (getters) and writing (setters) private attributes
void main() {
    Person p = new Person("Jean", "Michelin", 34);
    IO.println("p=(" + p.getFirstName() + "," + p.getLastName() + "," + p.getAge() + ")");
    p.setAge(56);
    IO.println("p=(" + p.getFirstName() + "," + p.getLastName() + "," + p.getAge() + ")");
}

class Person {
    // attributes
    private String firstName;
    private String lastName;
    private int age;

    // constructor
    public Person(String firstName, String lastName, int age) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.age = age;
    }

    // getters
    public String getFirstName() {
        return firstName;
    }

    public String getLastName() {
        return lastName;
    }

    public int getAge() {
        return age;
    }

    // setters
    public void setFirstName(String firstName) {
        this.firstName = firstName;
    }

    public void setLastName(String lastName) {
        this.lastName = lastName;
    }

    public void setAge(int age) {
        this.age = age;
    }
}
  • lines 23–33: the read methods, called getters;
  • Lines 36–46: Writing methods, called "setters."

Results:

1
2
3
> java Classes06.java
p=(Jean,Michelin,34)
p=(Jean,Michelin,56)

The names of these methods follow a convention established since the early days of Java, namely “JavaBeans”: the getter for the field [age] is named [getAge], and its setter is named [setAge]; the getter for a boolean field named [active] is instead called [isActive]. Many libraries (such as Jackson for JSON and Hibernate for databases, which we’ll use later) rely on this convention to retrieve an object’s data. Unlike other languages such as C#, Java does not have “properties”: accessing an object’s state always involves methods, or—for immutable objects—records, which we’ll cover later. Development environments can generate getters and setters from fields (in IntelliJ and IDEA, under the “Code / Generate…” menu).

4.1.15. Setters that validate received values

The advantage of modifiers over public fields is the ability to control the values assigned to the fields: a modifier can verify the validity of the received value and throw an exception if it is invalid. Consider the program [Classes07.java]:

// setters that check the validity of the values received
void main() {
    Person p = new Person("Jean", "Michelin", 34);
    IO.println("p=(" + p.getFirstName() + "," + p.getLastName() + "," + p.getAge() + ")");
    p.setAge(56);
    IO.println("p=(" + p.getFirstName() + "," + p.getLastName() + "," + p.getAge() + ")");
    try {
        p.setAge(-4);
    } catch (IllegalArgumentException ex) {
        IO.println(ex.getMessage());
    }
    // parameterless constructor then calls to the setters
    Person p2 = new Person();
    p2.setAge(7);
    p2.setFirstName("Arthur");
    p2.setLastName("Martin");
    IO.println("p2=(" + p2.getFirstName() + "," + p2.getLastName() + "," + p2.getAge() + ")");
    try {
        Person p3 = new Person();
        p3.setFirstName("Paul");
        p3.setLastName(" ");
    } catch (IllegalArgumentException ex) {
        IO.println(ex.getMessage());
    }
}

class Person {
    // attributes
    private String firstName = "";
    private String lastName = "";
    private int age;

    // constructors
    public Person(String firstName, String lastName, int age) {
        // we go through the setters to benefit from their checks
        setFirstName(firstName);
        setLastName(lastName);
        setAge(age);
    }

    public Person() {
    }

    // getters
    public String getFirstName() {
        return firstName;
    }

    public String getLastName() {
        return lastName;
    }

    public int getAge() {
        return age;
    }

    // setters: they are declared final because the constructor calls them
    public final void setFirstName(String firstName) {
        // valid first name?
        if (firstName == null || firstName.isBlank()) {
            throw new IllegalArgumentException("first name (" + firstName + ") invalid");
        }
        this.firstName = firstName;
    }

    public final void setLastName(String lastName) {
        // valid last name?
        if (lastName == null || lastName.isBlank()) {
            throw new IllegalArgumentException("last name (" + lastName + ") invalid");
        }
        this.lastName = lastName;
    }

    public final void setAge(int age) {
        // valid age?
        if (age < 0) {
            throw new IllegalArgumentException("age (" + age + ") invalid");
        }
        this.age = age;
    }
}

The three modifiers validate the received value (lines 60–62, 68–70, 76–78).

  • Lines 60–62: The method [isBlank] (Java 11) returns [true] if the string is empty or consists solely of spaces. The [firstName == null] test is performed first: if [firstName] is equal to [null], the operator [||] does not evaluate its second operand, which would cause a [NullPointerException] exception. The exception thrown is of type [IllegalArgumentException], the exception provided by Java to signal an invalid parameter. This is an uncaught exception: the method does not need to declare it with [throws];
  • lines 34–39: the constructor initializes the object using accessors rather than fields. This allows it to take advantage of the validations performed by the accessors, which are written in only one place;
  • lines 58, 66, 74: the modifiers are declared as [final]. We will see with inheritance that a derived class can override the methods of its parent class; the keyword [final] prevents this. Without it, a derived class could redefine [setAge], and the constructor of [Person] would call this new version even before the derived class’s constructor has been executed. The compiler, with the [-Xlint:all] option, flags this risk (warning [this-escape]) for a public class written in its own file;
  • lines 41–42: a parameterless constructor, which leaves the fields at their initial values.

Results:

1
2
3
4
5
6
> java Classes07.java
p=(Jean,Michelin,34)
p=(Jean,Michelin,56)
age (-4) invalid
p2=(Arthur,Martin,7)
last name ( ) invalid

Line 4 of the results shows that the call to [p.setAge(-4)] (line 8) was rejected by the modifier. Lines 13–16: An instance is created using the parameterless constructor and then initialized by its setters. Lines 19–21: The name containing a space is rejected (line 6 of the results).

Unlike other languages, Java does not have an object initializer—a syntax that would allow multiple fields to be set within the [new] expression itself: instead, as shown here, we chain the constructor and setter calls, or we write a constructor that takes all the values. We’ll look at two other solutions: “fluid” modifiers and records.

4.1.16. Final fields and required parameters

A field declared as [final] must be assigned a value exactly once—either at declaration or within each constructor—and cannot be modified thereafter. The compiler enforces this rule. Combined with the absence of modifiers, this allows for the creation of read-only data. The program [Classes08.java] demonstrates several cases:

// final fields, read-only, mandatory parameters
void main() {
    Person p = new Person("Jean", "Dupont", 30, "Angers");
    IO.println(p.getIdentity());
    // the first name is read / write
    p.setFirstName("Jacques");
    // the age can only be modified inside the class
    p.growOlder();
    IO.println(p.getIdentity());
    // the following lines are rejected by the compiler
    // p.setLastName("Durand"); // there is no setter for the last name
    // p.setAge(40);         // there is no setter for the age
    // p.city = "Paris";     // the city field is final

    // a mandatory parameter
    Contact c = new Contact("jean.dupont@example.com");
    IO.println("Contact: " + c.getEmail() + ", " + c.getPhone());
    try {
        Contact c2 = new Contact(null);
        IO.println("Contact: " + c2.getEmail());
    } catch (NullPointerException ex) {
        IO.println("Error: " + ex.getMessage());
    }
}

class Person {
    // fields
    private String firstName;
    // final fields: they receive their value once and for all in the constructor
    private final String lastName;
    private final String city;
    private int age;

    // constructor
    public Person(String firstName, String lastName, int age, String city) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.age = age;
        this.city = city;
    }

    // first name read / write
    public String getFirstName() {
        return firstName;
    }

    public void setFirstName(String firstName) {
        this.firstName = firstName;
    }

    // last name, age and city read-only
    public String getLastName() {
        return lastName;
    }

    public int getAge() {
        return age;
    }

    public String getCity() {
        return city;
    }

    // a computed value
    public String getIdentity() {
        return "[" + firstName + ", " + lastName + ", " + age + ", " + city + "]";
    }

    // the age is only modified by the methods of the class
    public void growOlder() {
        age++;
    }
}

class Contact {
    // the email is mandatory, the phone optional
    private final String email;
    private final String phone;

    // constructor with both values
    public Contact(String email, String phone) {
        this.email = Objects.requireNonNull(email, "the email is mandatory");
        this.phone = phone;
    }

    // constructor without the phone: default value
    public Contact(String email) {
        this(email, "unknown");
    }

    public String getEmail() {
        return email;
    }

    public String getPhone() {
        return phone;
    }
}
  • lines 28 and 43–49: the first name is both read-only and writable; it has a getter and a setter;
  • lines 30–31: the last name and city are [final] fields. The constructor assigns a value to them (lines 37 and 39), which cannot be changed afterward;
  • line 32 and lines 56–58: The age is readable by everyone but does not have a public setter; it can only be modified by the class’s methods, such as [growOlder] (lines 70–72);
  • lines 65–67: the method [getIdentity] returns a value calculated from the fields. To the user of the class, it is indistinguishable from an accessor;
  • lines 11–13: three statements that the compiler rejects, for the reasons given in the comments. For the third one, the compiler’s message is: [cannot assign a value to final variable city];
  • Lines 75–98: The [Contact] class has one required field—email—and one optional field—phone. The required field is enforced by the constructor: a [Contact] object cannot be created without an email address. The [Objects.requireNonNull(value, message)] method (line 82) returns its first parameter if it is not [null] and throws a [NullPointerException] exception with the specified message otherwise: An email address of the form [null] is rejected (lines 18–23);
  • lines 87–89: the optional nature of the phone number is expressed through a second constructor, which provides a default value. Java does not have optional parameters: we overload the constructor.

Note that in a compact file, the method [main] has access to the private fields of the file’s classes: this is why the comments on lines 11–13 do not rely on the private nature of the fields, but rather on the absence of a modifier and on the keyword [final].

Results:

1
2
3
4
5
> java Classes08.java
[Jean, Dupont, 30, Angers]
[Jacques, Dupont, 31, Angers]
Contact: jean.dupont@example.com, unknown
Error: the email is mandatory

4.1.17. Fluid modifiers

When an object has many optional fields, the number of constructors increases and modifier calls become longer. A common technique is to have each modifier return the current object ([this]): the calls can then be chained together; these are referred to as “fluid” methods. The program [Classes09.java] illustrates this:

// "fluent" setters: they return the current object
void main() {
    Person p = new Person().setFirstName("  jean ").setLastName("Dupont").setAge(30);
    IO.println(p.getIdentity());
    try {
        p.setLastName("");
    } catch (IllegalArgumentException ex) {
        IO.println(ex.getMessage());
    }
    try {
        p.setAge(-4);
    } catch (IllegalArgumentException ex) {
        IO.println(ex.getMessage());
    }
    IO.println(p.getIdentity());
}

class Person {
    // fields
    private String firstName = "";
    private String lastName = "";
    private int age;

    // fluent setters: each one returns the current object (this)
    public Person setFirstName(String firstName) {
        this.firstName = check(firstName, "first name");
        return this;
    }

    public Person setLastName(String lastName) {
        this.lastName = check(lastName, "last name");
        return this;
    }

    public Person setAge(int age) {
        if (age < 0) {
            throw new IllegalArgumentException("age (" + age + ") invalid");
        }
        this.age = age;
        return this;
    }

    // identity
    public String getIdentity() {
        return "[" + firstName + ", " + lastName + ", " + age + "]";
    }

    // checking a mandatory string: it is returned without its leading and trailing spaces
    private static String check(String value, String label) {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException(label + " (" + value + ") invalid");
        }
        return value.strip();
    }
}
  • lines 25–28: the modifier [setFirstName] checks the received value, assigns it to the field, and returns the current object. Its return type is therefore [Person];
  • line 3: the calls are chained: [new Person()] returns a person, on which [setFirstName] is called, which returns the same person, on which [setLastName] is called, and so on. The syntax resembles an object initializer;
  • Lines 49–54: The checks for the first name and last name, which are identical, are factored into the private method [check]. It is declared as [static] because it does not use any fields of the object (static members are discussed in the following paragraph). The method [strip] (Java 11) removes the leading and trailing spaces from the string.

Results:

1
2
3
4
5
> java Classes09.java
[jean, Dupont, 30]
last name () invalid
age (-4) invalid
[jean, Dupont, 30]

The first name “jean,” provided with spaces on either side (line 3), was stored without them (line 2 of the results). Fluid modifiers do not follow the JavaBeans convention, according to which a modifier returns nothing ([void]): some libraries do not recognize them. To construct complex objects, the “Builder” design pattern is also used: an auxiliary class, equipped with fluid methods, collects the values and then constructs the final object, which can then be immutable.

4.1.18. Class Methods and Attributes

Suppose we want to count the number of [Person] objects created in an application. We could manage a counter ourselves, but we risk overlooking temporary objects that are created here and there. It seems safer to include an instruction in the constructors of the [Person] class that increments a counter. This counter cannot be an ordinary field, since each object has its own fields: it is a field of the class itself and not that of a particular instance of that class. It is declared using the keyword [static]: [private static long nbPersons;].

To refer to it, we write [Person.nbPersons] to indicate that it is a field of the [Person] class itself. Here, we have created a private field that cannot be accessed directly from outside the class. We therefore create a public accessor to provide access to the class field [nbPersons]. To retrieve the value of [nbPersons], this accessor does not require a specific [Person] object; therefore, we also declare it as *[static]. From the outside, it will be called using the syntax [Person.getNbPersons()]. Here is an example of [Classes10.java]*:

// class members (static)
void main() {
    Person p1 = new Person("Jean", "Dupont", 30);
    Person p2 = new Person(p1);
    new Person(p1);
    IO.println("Number of persons created: " + Person.getNbPersons());
    // a class method
    Person p3 = Person.unknown();
    p3.identify();
    IO.println("Number of persons created: " + Person.getNbPersons());
}

// static nested class: see the explanations
static class Person {
    // class attribute
    private static long nbPersons;

    // class getter
    public static long getNbPersons() {
        return nbPersons;
    }

    // class method
    public static Person unknown() {
        return new Person("?", "?", 0);
    }

    // instance attributes
    private String firstName;
    private String lastName;
    private int age;

    // constructors
    public Person(String firstName, String lastName, int age) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.age = age;
        nbPersons++;
    }

    public Person(Person p) {
        this(p.firstName, p.lastName, p.age);
    }

    // identity of the person
    public void identify() {
        IO.println("[" + firstName + ", " + lastName + ", " + age + "]");
    }
}
  • line 16: the class field. Like any numeric field, it is initialized to 0;
  • lines 19–21: the class accessor;
  • lines 24–26: a class method. It is called using the class name: [Person.unknown()] (line 8). A class method has no current object: the keyword [this] has no meaning there, and it can only directly use the class members [static]. A static method that creates and returns an object of its class, such as this one, is called a static factory;
  • line 38: the primary constructor increments the counter. The copy constructor, which is chained to the primary constructor, therefore increments it as well;
  • Line 14: The class [Person] is declared as [static]. As explained in the section on compact files: a class declared in a compact file is a nested class of the implicit class. Without the [static] keyword, it is an internal class, where each object is bound to an object of the enclosing class; the static method [unknown], which has no current object, would be unable to create one, and the compiler would report the error [non-static variable this cannot be referenced from a static context]. A class written in its own file does not have this problem.

Results:

1
2
3
4
> java Classes10.java
Number of persons created: 3
[?, ?, 0]
Number of persons created: 4

The program correctly counted the temporary object created on line 5. We have been using class members from the beginning: [IO.println], [Integer.parseInt], [Math.sqrt] are [static] methods, and [Math.PI] is a [static] field. The class [Math] contains only static members: it is declared as [final], and its sole constructor is private, which prevents the creation of instances. This is the Java way of writing a utility class.

4.1.19. An array of people

An object is a piece of data like any other, and as such, multiple objects can be grouped together in an array [Classes11.java]:

// an array of persons
void main() {
    Person[] friends = new Person[3];
    friends[0] = new Person("Jean", "Dupont", 30);
    friends[1] = new Person("Sylvie", "Vartan", 52);
    friends[2] = new Person("Neil", "Armstrong", 66);
    // display
    for (Person friend : friends) {
        friend.identify();
    }
}

class Person {
    // attributes
    private String firstName;
    private String lastName;
    private int age;

    // constructor
    public Person(String firstName, String lastName, int age) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.age = age;
    }

    // identity of the person
    public void identify() {
        IO.println("[" + firstName + ", " + lastName + ", " + age + "]");
    }
}
  • Line 3: creates an array of 3 elements of type [Person]. These 3 elements are initialized here with the value [null], meaning they do not reference any objects. Technically speaking, we refer to this as an “array of objects,” even though it is actually just an array of object references. The creation of the array—which is itself an object (as indicated by [new])—does not create any objects of the same type as its elements; this must be done subsequently;
  • lines 4–6: creation of the 3 objects of type [Person];
  • lines 8–10: display the contents of the array [friends].

Results:

1
2
3
4
> java Classes11.java
[Jean, Dupont, 30]
[Sylvie, Vartan, 52]
[Neil, Armstrong, 66]

4.1.20. Type inference and array initialization

The program [Classes12.java] features two constructs that simplify the creation of objects and arrays:

// type inference with var and array initialization
void main() {
    // the type of the variable is deduced from the expression that initializes it
    var p1 = new Person("Jean", "Dupont", 30);
    var p2 = new Person();
    p2.setFirstName("Arthur");
    p2.setLastName("Martin");
    p2.setAge(7);
    // an array created and initialized by a list of values between braces
    Person[] friends = {
        p1,
        p2,
        new Person("Sylvie", "Vartan", 52),
        new Person("Neil", "Armstrong", 66),
    };
    for (var friend : friends) {
        IO.println(friend.getIdentity());
    }
    // an anonymous array passed as a parameter
    display(new Person[] { new Person("Pauline", "Leduc", 12), p1 });
}

// a method that receives an array of persons
void display(Person[] persons) {
    for (Person p : persons) {
        IO.println("Display: " + p.getIdentity());
    }
}

class Person {
    // fields
    private String firstName = "";
    private String lastName = "";
    private int age;

    // constructors
    public Person(String firstName, String lastName, int age) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.age = age;
    }

    public Person() {
    }

    // setters
    public void setFirstName(String firstName) {
        this.firstName = firstName;
    }

    public void setLastName(String lastName) {
        this.lastName = lastName;
    }

    public void setAge(int age) {
        this.age = age;
    }

    // identity
    public String getIdentity() {
        return "[" + firstName + ", " + lastName + ", " + age + "]";
    }
}
  • Line 4: With the keyword [var] (Java 10), the type of the local variable is inferred from the expression that initializes it, in this case [Person]. The variable is nonetheless typed: [p1] is of type [Person] and can only reference a single person. [var] can only be used for local variables initialized at declaration, not for fields or parameters;
  • lines 10–15: an array created and populated with a list of values enclosed in curly braces and separated by commas. The size of the array is the number of values. A trailing comma (line 14) is allowed: it makes it easier to add an element. This syntax is only allowed in the array declaration;
  • line 16: [var] within a loop [for];
  • line 20: outside of an array declaration, an initialized array is created by specifying its type: [new Person[] { ... }]. This is called an anonymous array: it is not referenced by any variable in the [main] method;
  • Lines 24–28: A method in the compact file that receives an array of people.

Results:

1
2
3
4
5
6
7
> java Classes12.java
[Jean, Dupont, 30]
[Arthur, Martin, 7]
[Sylvie, Vartan, 52]
[Neil, Armstrong, 66]
Display: [Pauline, Leduc, 12]
Display: [Jean, Dupont, 30]

4.2. Inheritance by Example

4.2.1. Overview

Here we introduce the concept of inheritance. The purpose of inheritance is to “customize” an existing class so that it meets our needs. Suppose we want to create a class [Teacher]: a teacher is a specific type of person. They have attributes that other people do not have: the subject they teach, for example. But they also have the attributes common to all people: first name, last name, and age. A teacher is therefore a full member of the class [Person] but has additional attributes. Rather than creating a class [Teacher] from scratch, we prefer to build upon the existing class [Person] and adapt it to the specific characteristics of teachers. This is made possible by the concept of inheritance.

To indicate that the class [Teacher] inherits from the class [Person], we write [public class Teacher extends Person].

[Person] is called the parent class (or base class, or superclass), and [Teacher] is called the derived class (or child class, or subclass). A [Teacher] object has all the properties of a [Person] object: it has the same fields and methods. These fields and methods from the parent class are not repeated in the definition of the child class; we simply specify the fields and methods added by the child class. A Java class can inherit from only one parent class.

The examples in this paragraph are two multi-file programs, located in the [Inheritance1] and [Inheritance2] folders. In the [Inheritance1] folder, the [Person] class is defined as follows: [Person.java]:

public class Person {
    // class field: the number of persons created
    private static long nbPersons;

    // instance fields
    private String firstName;
    private String lastName;
    private int age;

    // constructor
    public Person(String firstName, String lastName, int age) {
        setFirstName(firstName);
        setLastName(lastName);
        setAge(age);
        nbPersons++;
        IO.println("Constructor Person(String, String, int)");
    }

    // number of persons created
    public static long getNbPersons() {
        return nbPersons;
    }

    // getters
    public String getFirstName() {
        return firstName;
    }

    public String getLastName() {
        return lastName;
    }

    public int getAge() {
        return age;
    }

    // setters
    public final void setFirstName(String firstName) {
        this.firstName = check(firstName, "first name");
    }

    public final void setLastName(String lastName) {
        this.lastName = check(lastName, "last name");
    }

    public final void setAge(int age) {
        if (age < 0) {
            throw new IllegalArgumentException("age (" + age + ") invalid");
        }
        this.age = age;
    }

    // identity of the person
    public String getIdentity() {
        return "[" + firstName + ", " + lastName + ", " + age + "]";
    }

    // checking a mandatory string
    private static String check(String value, String label) {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException(label + " (" + value + ") invalid");
        }
        return value;
    }
}
  • line 3: the person counter from the previous paragraph;
  • lines 11–17: the constructor initializes the object via its modifiers, increments the counter, and displays a progress message;
  • lines 38–51: three modifiers that validate their values. The validations for the first name and last name, which are identical, are factored into the private class method [check] (lines 59–64). The modifiers are declared as [final] because the constructor calls them (see the program [Classes07.java]);
  • lines 54–56: the [identify] method from the previous examples has been replaced by the [getIdentity] method, which returns the person’s identity instead of displaying it.

We create a class named [Teacher] that inherits from the [Person] class: [Teacher.java]:

public class Teacher extends Person {
    // field specific to the Teacher class
    private int section;

    // constructor
    public Teacher(String firstName, String lastName, int age, int section) {
        // construction of the Person part of the object
        super(firstName, lastName, age);
        // we store the section
        this.section = section;
        // tracing
        IO.println("Constructor Teacher(String, String, int, int)");
    }

    // getter and setter of the section
    public int getSection() {
        return section;
    }

    public void setSection(int section) {
        this.section = section;
    }
}

The [Teacher] class adds to the fields and methods of the [Person] class:

  • line 1: the class [Teacher] derives from the class [Person];
  • line 3: a field [section], which is the number of the section to which the teacher belongs within the teaching staff (roughly one section per subject);
  • lines 6–13: a constructor used to initialize all of a teacher’s fields;
  • lines 16–22: the getter and setter for the [section] field.

4.2.2. Creating a Teacher Object

A child class does not inherit constructors from its parent class. It must define its own constructors. The constructor for the [Teacher] class is as follows:

1
2
3
4
5
6
7
8
    public Teacher(String firstName, String lastName, int age, int section) {
        // construction of the Person part of the object
        super(firstName, lastName, age);
        // we store the section
        this.section = section;
        // tracing
        IO.println("Constructor Teacher(String, String, int, int)");
    }

The constructor receives four parameters ([firstName], [lastName], [age], [section]) and passes three ([firstName], [lastName], [age]) to its parent class—in this case, the class [Person]—using the statement [super(firstName, lastName, age);] (line 3). We know that this class has a constructor, [Person(String, String, int)], which will be used to create a person with the passed parameters. Once the construction of the [Person] part is complete, the construction of the [Teacher] object continues by executing the rest of the constructor (lines 5–7).

In summary, the constructor of a derived class:

  • passes to its parent class, via [super(...)], the parameters that the parent class needs to construct itself;
  • uses the other parameters to initialize its own fields.

We could have chosen to initialize the fields [firstName], [lastName], and [age] directly in the constructor of class [Teacher], using statements like [this.firstName = firstName;]. This is impossible for two reasons. First, the private fields [firstName], [lastName], and [age] are accessible only to methods of the [Person] class. All other objects, including objects of derived classes as in this case, must access them through public (or protected) members. This would have been different if the class [Person] had declared these fields as protected (*[protected]*): it would then have allowed derived classes to access them directly. Furthermore, the [Person] part of the object must in any case be constructed by a constructor of the [Person] class: in the absence of a call to [super(...)], the compiler inserts a call to [super()]—the parameterless constructor of the parent class. However, the class [Person] does not have one. If the [super(firstName, lastName, age);] statement is removed, the compiler reports the following error:

1
2
3
4
5
6
7
8
9
> java Main.java
.../Inheritance1/Teacher.java:6: error: constructor Person in class Person cannot be applied to given types;
    public Teacher(String firstName, String lastName, int age, int section) {
                                                                            ^
  required: String,String,int
  found:    no arguments
  reason: actual and formal argument lists differ in length
1 error
error: compilation failed

Using the parent class’s constructor is therefore the correct solution and the standard approach: when constructing a child object, first call the parent object’s constructor, then complete the initializations specific to the child object ([section] in our example). As with [this(...)], only statements that do not use the object being constructed may precede the call to [super(...)] (since Java 25; previously, [super(...)] had to be the first statement).

4.2.3. Inherited Methods

The class [Teacher] does not define a method named [getIdentity]. The expression [e.getIdentity()], where [e] is a teacher, therefore refers to the method [getIdentity] inherited from the class [Person]: it is public and, by inheritance, becomes a public method of the [Teacher] class. The test program [Main.java] in the [Inheritance1] folder is as follows:

void main() {
    // a teacher
    Teacher e = new Teacher("Lucile", "Dumas", 56, 61);
    // the getIdentity method inherited from the Person class
    IO.println(e.getIdentity());
    // the getSection method specific to the Teacher class
    IO.println("section=" + e.getSection());
    // a person
    Person p = new Person("Jean", "Dupont", 30);
    // the display method receives a Person or an object of a derived class
    display(e);
    display(p);
    // the toString method inherited from the Object class
    IO.println(e.toString());
    IO.println(p.toString());
    IO.println("Number of persons created: " + Person.getNbPersons());
}

// displays the identity of p
void display(Person p) {
    IO.println(p.getIdentity());
}
  • line 3: creates a teacher;
  • line 5: displays the teacher’s identity, obtained via the [getIdentity] method inherited from the [Person] class;
  • line 7: the [getSection] method, specific to the [Teacher] class;
  • lines 11–12: call to the [display] method (lines 20–22) with a teacher and then a person. We’ll come back to this in the next paragraph;
  • lines 14–15: call to the [toString] method for both objects. We’ll also come back to this.

Results:

> java Main.java
Constructor Person(String, String, int)
Constructor Teacher(String, String, int, int)
[Lucile, Dumas, 56]
section=61
Constructor Person(String, String, int)
[Lucile, Dumas, 56]
[Jean, Dupont, 30]
Teacher@70325e14
Person@37ceb1df
Number of persons created: 2

We can see that:

  • an object [Person] (line 2 of the results) was constructed before the object [Teacher] (line 3);
  • [e.getIdentity()] provided the identity of the teacher’s [Person] part, without its section (line 4);
  • the person counter also counts the teacher (line 11): a teacher is a person.

To obtain a teacher’s complete identity, the class [Teacher] must define its own method [getIdentity]. This is what the folder [Inheritance2] does.

4.2.4. Polymorphism

Consider a class hierarchy: C0 ← C1 ← C2 ← … ← Cn, where Ci ← Cj indicates that class Cj is derived from class Ci. This implies that class Cj has all the characteristics of class Ci plus additional ones. Let Oi be objects of type Ci. It is valid to write [Oi = Oj] with j > i. Indeed, by inheritance, class Cj has all the characteristics of class Ci plus additional ones. Therefore, an object Oj of type Cj contains an object of type Ci. The operation [Oi = Oj] causes the variable Oi, of type Ci, to reference the object Oj: only the members defined in class Ci can be accessed through it.

The fact that a variable Oi of class Ci can actually reference not only an object of class Ci but any object derived from class Ci is called polymorphism: the ability of a variable to reference different types of objects.

Let’s take an example and consider the method [display] from the previous program:

1
2
3
void display(Person p) {
    IO.println(p.getIdentity());
}

We can pass it either a [Person] or a [Teacher]: In the latter case, the formal parameter [p], which is of type [Person], receives a value of type [Teacher]. Since the type [Teacher] is derived from the type [Person], this is valid. The [p.getIdentity()] statement here executed the [getIdentity] method of the [Person] class, the only one that exists in the program: for the teacher (line 7 of the results), we only get the identity of its “Person” part. Let’s see what happens when the class [Teacher] has its own method [getIdentity].

4.2.5. Method Overriding and Dynamic Linking

In the [Inheritance2] folder, the [Person] class is the same as the one in the [Inheritance1] folder with one addition:

    // identity of the person: method overridden in the Teacher class
    public String getIdentity() {
        return "[" + firstName + ", " + lastName + ", " + age + "]";
    }

    // overriding the toString method of the Object class
    @Override
    public String toString() {
        return getIdentity();
    }
  • lines 6–10: a redefinition of the method [toString], discussed in the following paragraph.

In the [Teacher] class, the [getIdentity] method is redefined as follows:

// a final class: it cannot be derived from
public final class Teacher extends Person {
    // field specific to the Teacher class
    private int section;

    // constructor
    public Teacher(String firstName, String lastName, int age, int section) {
        super(firstName, lastName, age);
        this.section = section;
        IO.println("Constructor Teacher(String, String, int, int)");
    }

    // getter and setter of the section
    public int getSection() {
        return section;
    }

    public void setSection(int section) {
        this.section = section;
    }

    // the getIdentity method overrides the one of the parent class
    @Override
    public String getIdentity() {
        return "Teacher[" + super.getIdentity() + "," + section + "]";
    }
}
  • Lines 23–26: The method [getIdentity] overrides the method in the parent class; it has the same name, the same parameters, and the same return type. It relies on the method [getIdentity] from its parent class, referred to as [super.getIdentity()], to retrieve the “Person” portion, then supplements it with the section specific to the class [Teacher];
  • Line 23: The annotation [@Override] tells the compiler that the method intends to override a method of the parent class. It is not mandatory, but it is strongly recommended: if the method did not override anything (due to a typo in the name or different parameters), the compiler would report the error instead of silently creating a new method;
  • Line 2: The class is final ([final]), which we’ll explain in more detail later.

Consider a teacher object [E]. This object contains a [Person] object within it:

Image

The method [getIdentity] is defined in both the class [Teacher] and its parent class [Person]. In Java, all instance methods that are neither private nor final can be overridden, and the choice of which method is executed by [p.getIdentity()] is made at runtime, based on the actual type of the object referenced by [p], rather than at compile time based on the declared type of the variable. This is known as dynamic binding. To execute [o.m()], the virtual machine searches for a method named [m] in the following order:

  • in the class of the [o] object;
  • in its parent class, if it has one;
  • in the parent class of its parent class, if it exists;
  • and so on.

Inheritance therefore allows you to redefine methods in the child class that have the same signature as those in the parent class. This is what enables you to adapt the child class to its own needs. The program [Main.java] in the folder [Inheritance2] is as follows:

void main() {
    // a teacher
    Teacher e = new Teacher("Lucile", "Dumas", 56, 61);
    display(e);
    // a person
    Person p = new Person("Jean", "Dupont", 30);
    display(p);
    // seen as a Person, the teacher remains a teacher
    Person pe = e;
    IO.println(pe.getIdentity());
    IO.println(((Person) e).getIdentity());
    // IO.println(Object) displays the result of the toString method of the object
    IO.println(e);
    IO.println(p);
    // an array of persons of different types
    Person[] persons = { p, e };
    for (Person person : persons) {
        IO.println(person.getClass().getSimpleName() + " : " + person);
    }
}

// displays the identity of p
void display(Person p) {
    IO.println(p.getIdentity());
}

Results:

> java Main.java
Constructor Person(String, String, int)
Constructor Teacher(String, String, int, int)
Teacher[[Lucile, Dumas, 56],61]
Constructor Person(String, String, int)
[Jean, Dupont, 30]
Teacher[[Lucile, Dumas, 56],61]
Teacher[[Lucile, Dumas, 56],61]
Teacher[[Lucile, Dumas, 56],61]
[Jean, Dupont, 30]
Person : [Jean, Dupont, 30]
Teacher : Teacher[[Lucile, Dumas, 56],61]

This time, on line 4 of the results, the [display] method correctly displayed the teacher’s full name. Lines 9–11 of the program show that, whether viewed through a variable of type [Person] or converted to [Person] by the operator [(Person)], the teacher remains a teacher: it is still the method [getIdentity] of the class [Teacher] that is executed (lines 7–8 of the results). The conversion changes the type of the reference, not that of the object. Unlike other languages such as C#, where a method can only be overridden if it is declared virtual, Java methods are “virtual” by default; there is no such thing as overriding an instance method.

4.2.6. The Object class and the toString method

The [java.lang.Object] class is the “parent” class of all Java classes. Thus, when you write [public class Person], you are implicitly writing [public class Person extends Object].

The [Object] class defines several methods that all objects inherit:

  • [String toString()]: returns a textual representation of the object. By default, this is the object’s class name, followed by the character [@] and a hash code written in hexadecimal;
  • [boolean equals(Object o)]: indicates whether the object is “equal” to the object [o]. By default, it is equal only to itself: the method compares references, like the operator [==];
  • [int hashCode()]: returns a hash code for the object, used by dictionaries and sets (see the next chapter). The methods [equals] and [hashCode] are discussed later;
  • [Class<?> getClass()]: returns the description of the object’s actual class. The method [getSimpleName] in this description returns the class name, while [getName] returns its full name.

It is the method [toString] of the class [Object] that was used by the calls [e.toString()] and [p.toString()] in the folder [Inheritance1]: [Teacher@70325e14] and [Person@37ceb1df] (lines 9–10 of the results; the numbers vary from one execution to another). Although we did not redefine the method [toString] in the classes [Person] and [Teacher], the method [toString] in the class [Object] was able to display the actual class name of the object.

In the [Inheritance2] folder, the [Person] class redefines the [toString] method (see above). Let’s return to the program [Main.java] in the folder [Inheritance2]:

1
2
3
4
5
6
7
8
    // IO.println(Object) displays the result of the toString method of the object
    IO.println(e);
    IO.println(p);
    // an array of persons of different types
    Person[] persons = { p, e };
    for (Person person : persons) {
        IO.println(person.getClass().getSimpleName() + " : " + person);
    }

Line 2: The [IO.println(Object)] method accepts a parameter of type [Object]; therefore, since [Object] is the superclass of all classes, any object—in this case, a [Teacher]—can be passed. It prints the result of the object’s [toString] method to the console. Since the binding is dynamic, the [toString] method of the actual object is used—in this case, the one inherited from the [Person] class. This returns [getIdentity()], a redefined method: For the teacher [e], the method [getIdentity] from the class [Teacher] is therefore executed, as shown in line 9 of the results. It is therefore unnecessary to redefine [toString] in the class [Teacher].

The same mechanism applies to the concatenation of a string and an object: in [" : " + person] (line 7), the object [person] is converted to a string by its method [toString]. The loop in lines 6–8 iterates over an array of references [Person] that actually point to objects of different types: [getClass().getSimpleName()] returns the actual class name of each object (lines 11–12 of the results).

4.2.7. Final classes and sealed classes

The class [Teacher] in the package [Inheritance2] is declared as [public final class Teacher extends Person]. The keyword [final] prevents classes from being derived from the class [Teacher]: the declaration [class TeacherResearcher extends Teacher] would be rejected by the compiler. A class is marked as final when it is not intended to be derived from. We have seen that the keyword also applies to methods: a [final] method cannot be overridden by derived classes without prohibiting derivation from that class. For example, the class [String], derived from JDK, is final.

Between a class open to all derivations and a final class, Java 17 has added an intermediate option: the sealed class ([sealed]), which provides an exhaustive list of its direct derived classes. The program [Classes13.java] illustrates this:

// final classes and sealed classes (Java 17)
void main() {
    Person[] persons = {
        new Teacher("Lucile", "Dumas", 61),
        new Student("Paul", "Martin", "computer science"),
        new Adjunct("Marc", "Petit"),
        new Consultant("Lise", "Morel", "Airbus"),
    };
    for (Person p : persons) {
        IO.println(p.getClass().getSimpleName() + " : " + p.getIdentity());
    }
    // the permitted direct subclasses of the sealed class Person
    IO.println("Person is sealed: " + Person.class.isSealed());
    for (Class<?> c : Person.class.getPermittedSubclasses()) {
        IO.println("permitted subclass: " + c.getSimpleName());
    }
}

// a sealed class: only the classes listed after permits can derive from it
abstract sealed class Person permits Teacher, Student, Adjunct {
    private final String firstName;
    private final String lastName;

    protected Person(String firstName, String lastName) {
        this.firstName = firstName;
        this.lastName = lastName;
    }

    public String getIdentity() {
        return "[" + firstName + ", " + lastName + "]";
    }
}

// a class derived from a sealed class must be final, sealed or non-sealed
final class Teacher extends Person {
    private final int section;

    public Teacher(String firstName, String lastName, int section) {
        super(firstName, lastName);
        this.section = section;
    }

    @Override
    public String getIdentity() {
        return super.getIdentity() + ", section " + section;
    }
}

final class Student extends Person {
    private final String major;

    public Student(String firstName, String lastName, String major) {
        super(firstName, lastName);
        this.major = major;
    }

    @Override
    public String getIdentity() {
        return super.getIdentity() + ", major " + major;
    }
}

// non-sealed: the hierarchy is open again from this class
non-sealed class Adjunct extends Person {
    public Adjunct(String firstName, String lastName) {
        super(firstName, lastName);
    }
}

// a class derived from Adjunct: allowed, since Adjunct is not sealed
class Consultant extends Adjunct {
    private final String company;

    public Consultant(String firstName, String lastName, String company) {
        super(firstName, lastName);
        this.company = company;
    }

    @Override
    public String getIdentity() {
        return super.getIdentity() + ", company " + company;
    }
}

// rejected by the compiler: Artist is not listed in the permits clause of Person
// final class Artist extends Person { ... }
  • Line 20: The abstract class (see below: you cannot create objects of this class, only of its derived classes) [Person] is sealed. Only the classes [Teacher], [Student], and [Adjunct], listed after the keyword [permits], can be derived from it. When derived classes are declared in the same file, as here, the [permits] clause may be omitted: the authorized classes are then those in the file that are directly derived from it. In a larger program, the authorized classes must belong to the same package as the sealed class (or to the same module if the sealed class belongs to a named module);
  • line 24: the constructor is protected ([protected]): it is intended only for derived classes;
  • Lines 35 and 49: A class derived from a sealed class must specify how it itself can be derived. The classes [Teacher] and [Student] are final;
  • line 64: the class [Adjunct] is declared as [non-sealed]: this reopens the hierarchy, allowing any class to derive from it. A third option is to declare it as [sealed] itself, with its own list of permitted classes;
  • lines 71–83: The class [Consultant] derives from [Adjunct], which is allowed;
  • lines 85–86: a class [Artist] derived from [Person] would be rejected;
  • Lines 13–16: The description of a class ([Person.class]) indicates whether it is sealed ([isSealed]) and lists its authorized direct derived classes ([getPermittedSubclasses]).

Results:

1
2
3
4
5
6
7
8
9
> java Classes13.java
Teacher : [Lucile, Dumas], section 61
Student : [Paul, Martin], major computer science
Adjunct : [Marc, Petit]
Consultant : [Lise, Morel], company Airbus
Person is sealed: true
permitted subclass: Teacher
permitted subclass: Student
permitted subclass: Adjunct

If you uncomment the class [Artist] (by providing it with a constructor), the compiler reports:

1
2
3
4
5
6
> java Classes13.java
Classes13.java:86: error: class is not allowed to extend sealed class: Classes13.Person (as it is not listed in its 'permits' clause)
final class Artist extends Person { Artist() { super("a", "b"); } }
      ^
1 error
error: compilation failed

What is the purpose of a sealed class? It allows the author of a class hierarchy to maintain control over it: the author knows that any [Person] is a [Teacher], a [Student], or a [Adjunct]. The compiler knows this as well and uses it in pattern filtering, which is discussed at the end of the chapter: a [switch] that handles all three cases is complete. The mechanism also applies to interfaces ([sealed interface]), which are discussed later.

4.3. No operator overloading: equals, compareTo…

Some languages, such as C# or C++, allow you to redefine the meaning of operators ([+], [==], [<]…) for a class. Java does not allow this: operators always have the meaning assigned to them by the language. The only exception is the [+] operator applied to a string, which performs concatenation and uses the [toString] method of the concatenated object to do so. Instead of operators, we write methods that follow well-established conventions:

Operation
Java Method
Example
Content equality ([==] compares references)
[boolean equals(Object o)], inherited from [Object]
[s1.equals(s2)]
Comparison ([<], [>]…)
[int compareTo(T other)] from the [Comparable<T>] interface: negative, null, or positive result
[s1.compareTo(s2) < 0]
arithmetic operations
explicitly named methods
[a.add(b)], [a.multiply(b)] for [BigDecimal]
Addition to a collection ([+=])
[add]
[list.add(p)]

4.3.1. An example

The folder [Lists] contains a class [ListOfPersons], a list of people, whose methods act as the operators [+] and [+=]. The classes [Person] and [Teacher] in this folder are simplified versions of those in the [Inheritance2] folder: the fields are [final] and have no modifiers; there are no longer any tracking messages or counters. The people are organized into a generic list [List<Person>] (generic classes are discussed later in this chapter; lists are covered in the next chapter). Rather than having the class [ListOfPersons] inherit from a list class derived from JDK, we place the list in a private field of the class. This is referred to as using composition rather than inheritance: the class [ListOfPersons] thus exposes only the operations it has chosen. From the interface [List<Person>], we use only the following elements:

  • the method [l.add(p)], which adds the person [p] to the list [l], and the [l.addAll(l2)] method, which adds all elements from the [l2] list to it;
  • the method [l.size()], which returns the number of elements in the list [l];
  • the methods [l.get(i)] and [l.set(i, p)], which read and modify element number i of the list [l].

The definition of class [ListOfPersons] is as follows: [ListOfPersons.java]:

import java.util.ArrayList;
import java.util.List;
import java.util.StringJoiner;

public class ListOfPersons {
    // the persons of the list
    private final List<Person> persons = new ArrayList<>();

    // number of persons in the list
    public int size() {
        return persons.size();
    }

    // the equivalent of a + operator: returns a new list containing the persons
    // of the current list and the person p
    public ListOfPersons plus(Person p) {
        ListOfPersons result = new ListOfPersons();
        result.persons.addAll(persons);
        result.persons.add(p);
        return result;
    }

    // the equivalent of a += operator: adds p to the current list, which is returned
    public ListOfPersons add(Person p) {
        persons.add(p);
        return this;
    }

    // the equivalent of an indexer: reading and writing element no. i
    public Person get(int i) {
        return persons.get(i);
    }

    public void set(int i, Person p) {
        persons.set(i, p);
    }

    // position of the person with the given last name, -1 if it does not exist
    public int indexOf(String lastName) {
        for (int i = 0; i < persons.size(); i++) {
            if (persons.get(i).getLastName().equals(lastName)) {
                return i;
            }
        }
        return -1;
    }

    // toString: returns (person1,person2,...)
    @Override
    public String toString() {
        StringJoiner joiner = new StringJoiner(",", "(", ")");
        for (Person p : persons) {
            joiner.add(p.toString());
        }
        return joiner.toString();
    }
}
  • lines 1–3: Unlike the compact file [Main.java], a regular file does not automatically import the classes from the [java.base] module: it must import the ones it uses (except for those from the [java.lang] package, such as [String], which are always known). The [import] clauses are provided with the packages;
  • line 7: the list of people. The keyword [final] indicates that the field will always refer to the same list; the list itself may change in content. [new ArrayList<>()] creates an empty list; the “diamond” operator [<>] allows the compiler to infer the type of the elements, [Person], from the field type;
  • lines 10–12: the number of people;
  • lines 16–21: the method [plus] acts as a [+] operator for the operation [l.plus(p)], where [l] is of type [ListOfPersons] and [p] is of type [Person] or a derivative thereof. It creates a new list (line 17), copies the people from the current list into it (line 18), and then adds the person [p] (line 19). As with the [+] operator for numbers or strings, the operands are not modified. Since the list [result] is of the same class, the method has access to its private field [result.persons]. The returned list is of type [ListOfPersons], which allows for chained calls as in [l.plus(p1).plus(p2)];
  • lines 24–27: the method [add] acts as a [+=] operator: it modifies the current list by adding the person [p] to it and returns that same list, which also allows for chained calls;
  • Lines 49–56: We redefine the [toString] method to display a list of people in the form (person1, person2, ...), where personi is itself the result of the person’s [toString] method. A [StringJoiner] object (line 51) constructs a string from successive elements ([add], line 53), separating them with the specified separator (in this case, a comma) and enclosing them with a prefix and a suffix (in this case, parentheses).

Note that the class [ListOfPersons] has no constructor. It therefore uses the default constructor, which takes no parameters and does nothing other than initialize the field [persons] with the value specified in its declaration. Lines 29–46 are discussed in the following section.

The test program [Main.java] begins as follows:

void main() {
    // --- methods instead of operators
    String s1 = "jean";
    String s2 = new StringBuilder("je").append("an").toString();
    IO.println("s1 == s2 : " + (s1 == s2) + ", s1.equals(s2) : " + s1.equals(s2));
    IO.println("s1.compareTo(\"paul\") : " + s1.compareTo("paul"));
    BigDecimal a = new BigDecimal("0.10"), b = new BigDecimal("0.20");
    IO.println("a + b = " + a.add(b) + ", a < b : " + (a.compareTo(b) < 0));

    // --- the plus method
    // a list of persons
    ListOfPersons l = new ListOfPersons();
    // adding persons
    l = l.plus(new Person("jean", "martin", 10)).plus(new Person("pauline", "leduc", 12));
    // display
    IO.println("l=" + l);
    // l.plus(p) returns a new list: l is not modified
    ListOfPersons l2 = l.plus(new Person("louis", "durand", 8));
    IO.println("l=" + l);
    IO.println("l2=" + l2);

    // --- the add method
    ListOfPersons l3 = l;
    // add modifies the list referenced by l, without creating a new list
    l.add(new Teacher("camille", "germain", 27, 60));
    IO.println("l=" + l);
    // l and l3 still reference the same object
    IO.println("l3 == l : " + (l3 == l));
  • Lines 3–5: two strings with the same content but distinct: the string [s2] is constructed at runtime. The operator [==] compares the references, while the method [equals] compares the contents. To compare two strings, you must always use [equals];
  • line 6: the method [compareTo] returns a negative number because “jean” comes before “paul” in the character code order;
  • Lines 7–8: The class [BigDecimal] (exact decimal numbers, which we will use for the amounts) replaces the operators [+] and [<] with the methods [add] and [compareTo];
  • line 12: creation of an empty list of people [l];
  • line 14: Add two people using the [plus] method;
  • line 18: [l.plus(p)] creates a new list, [l2]; the list [l] remains unchanged;
  • line 25: adds a teacher using the [add] method;
  • lines 16–20: concatenating a string and an object uses the [toString] method, which is redefined from the [ListOfPersons] class.

Results (beginning):

1
2
3
4
5
6
7
8
9
> java Main.java
s1 == s2 : false, s1.equals(s2) : true
s1.compareTo("paul") : -6
a + b = 0.30, a < b : true
l=([jean, martin, 10],[pauline, leduc, 12])
l=([jean, martin, 10],[pauline, leduc, 12])
l2=([jean, martin, 10],[pauline, leduc, 12],[louis, durand, 8])
l=([jean, martin, 10],[pauline, leduc, 12],Teacher[[camille, germain, 27],60])
l3 == l : true

Why are there two methods, when [l = l.plus(p)] produces the same output as [l.add(p)]? The [plus] method creates a new list, copies all the people from [l] into it, and then [l] references this new list, while the old one is discarded by the garbage collector. The method [add] adds the person to the existing list without copying them. Line 23 stores a second reference, [l3], to the list [l]; after the call to [l.add(...)], both references still point to the same object (line 9 of the results), which proves that the list was not recreated. For a large list, or within a loop, the difference in performance is significant. The choice between the two styles is evident in JDK: the immutable objects [String] and [BigDecimal] return new objects; lists and [StringBuilder] are modified.

4.4. No indexers: get and set methods

Here, we continue to use the [ListOfPersons] class. If [l] is a [ListOfPersons] object, we want to be able to refer to the person at index i in the [l] list for both reading and writing. Some languages allow you to define an “indexer” for this purpose, which enables the [l[i]] notation to be used on an object. Java does not allow this: the [[i]] notation is reserved for arrays. By convention, we write methods [get(int i)] and [set(int i, value)], just as the JDK lists do:

    // the equivalent of an indexer: reading and writing element no. i
    public Person get(int i) {
        return persons.get(i);
    }

    public void set(int i, Person p) {
        persons.set(i, p);
    }

    // position of the person with the given last name, -1 if it does not exist
    public int indexOf(String lastName) {
        for (int i = 0; i < persons.size(); i++) {
            if (persons.get(i).getLastName().equals(lastName)) {
                return i;
            }
        }
        return -1;
    }
  • lines 2–4: the method [get] returns person No. i from the private list [persons];
  • lines 6–8: the [set] method assigns person [p] to it;
  • lines 11–18: We also want to be able to obtain the position in the list of the person with a given name, or -1 if that person is not in the list. The name [indexOf] is that of the equivalent method for lists and strings, JDK. The names are compared using [equals] (line 13).

The test program continues as follows:

    // --- the get, set and indexOf methods instead of indexers
    // changing element no. 1
    l.set(1, new Person("franck", "gallon", 5));
    // displaying element no. 1
    IO.println("l.get(1)=" + l.get(1));
    // displaying the list l
    IO.println("l=" + l);
    // searching for persons by their last name
    String[] lastNames = { "martin", "germain", "xx" };
    for (String lastName : lastNames) {
        int index = l.indexOf(lastName);
        if (index != -1) {
            IO.println("Person(" + lastName + ")=" + l.get(index));
        } else {
            IO.println("Person(" + lastName + ") does not exist");
        }
    }
  • line 3: call to the [set] method;
  • line 5: call to method [get];
  • line 11: call to method [indexOf];
  • line 13: call to method [get].

Results (end):

1
2
3
4
5
6
...
l.get(1)=[franck, gallon, 5]
l=([jean, martin, 10],[franck, gallon, 5],Teacher[[camille, germain, 27],60])
Person(martin)=[jean, martin, 10]
Person(germain)=Teacher[[camille, germain, 27],60]
Person(xx) does not exist

4.5. No Structures: Primitive Types and Records

4.5.1. Value and Reference

Other languages, such as C or C#, offer structures in addition to classes: types whose variables directly contain the data, rather than a reference to an object. Java does not have them. It distinguishes between two families of types:

  • the eight primitive types ([byte], [short], [int], [long], [float], [double], [char], [boolean]): a variable of a primitive type contains the value itself;
  • reference types: classes (including [String]), interfaces, arrays, records, etc. A variable of a reference type contains a reference to an object, such as [null].

The program [Classes14.java] illustrates the difference:

// primitive types and reference types: copy of value, copy of reference
void main() {
    // two variables of primitive type
    int age1 = 10;
    int age2 = age1;
    IO.println("age1=" + age1 + ", age2=" + age2);
    // age2 is modified
    age2 = 30;
    // checking age1 and age2
    IO.println("age1=" + age1 + ", age2=" + age2);

    // an object op1
    CPerson op1 = new CPerson();
    op1.name = "paul";
    op1.age = 10;
    IO.println("op1=CPerson(" + op1.name + "," + op1.age + ")");
    // an object op2
    CPerson op2 = op1;
    IO.println("op2=CPerson(" + op2.name + "," + op2.age + ")");
    // op2 is modified
    op2.name = "nicole";
    op2.age = 30;
    // checking op1 and op2
    IO.println("op1=CPerson(" + op1.name + "," + op1.age + ")");
    IO.println("op2=CPerson(" + op2.name + "," + op2.age + ")");

    // an explicit copy of the object
    CPerson op3 = new CPerson();
    op3.name = op1.name;
    op3.age = op1.age;
    op3.age = 50;
    IO.println("op1=CPerson(" + op1.name + "," + op1.age + ")");
    IO.println("op3=CPerson(" + op3.name + "," + op3.age + ")");
}

// CPerson class
class CPerson {
    String name = "";
    int age;
}
  • Lines 37–40: a class with two fields accessible from the package: [name], [age]. The non-private fields—which are generally discouraged—make the example easier to read here.

Results:

1
2
3
4
5
6
7
8
9
> java Classes14.java
age1=10, age2=10
age1=10, age2=30
op1=CPerson(paul,10)
op2=CPerson(paul,10)
op1=CPerson(nicole,30)
op2=CPerson(nicole,30)
op1=CPerson(nicole,30)
op3=CPerson(nicole,50)

The declaration [int age1 = 10;] (line 4) reserves a memory location that contains the integer 10: the value of [age1] is the integer itself. In the case of the class, the object ([name], [age]) is created explicitly using the operator [new] (line 13). The object is placed in a memory area managed by the garbage collector, the heap, and the value of [op1] is the address (the reference) of this object.

When [int age2 = age1;] is written in the program (line 5), the value of [age1]—the integer 10—is copied into [age2]: this is a value copy. Modifying [age2] does not modify [age1] (line 3 of the results). Now consider the instruction [CPerson op2 = op1;] (line 18). The value of [op1] is copied to [op2], but since this value is the object’s address, the object itself is not duplicated: [op1] and [op2] refer to the same object. If the object pointed to by [op2] is modified, the object pointed to by [op1] is also modified since they are the same (lines 6–7 of the results). To obtain a second object, you must create it and copy the fields from the first one into it (lines 28–30): modifying the copy does not modify the original (lines 8–9 of the results).

Image

From these explanations, we can conclude that:

  • the value of a variable of a primitive type is the data itself;
  • the value of a reference-type variable is the address of the object it points to;
  • assignment and parameter passing always copy the value of the variable: the data itself for a primitive type, the reference for an object.

Each primitive type corresponds to a so-called “wrapper” class in the [java.lang] package: [Integer] for [int], [Double] for [double], [Boolean] for [boolean], and so on. The compiler automatically converts a primitive value to a wrapper object and vice versa (“autoboxing” and “unboxing”): we’ll need this with generics, which only accept reference types. A variable [Integer] can be equal to [null], but a variable [int] never can.

To represent a small composite piece of data (a point, a color, a date, an amount) that is treated as a value, Java uses immutable classes: their objects cannot be modified after they are created. Sharing a reference to an immutable object is safe: no one can modify it, and the object then behaves like a value. Two tools round out this system: the *[equals]* method, which compares two objects based on their content, and records, which declare an immutable class in a single line. This is the subject of the following two paragraphs.

4.5.2. Equality of Two Objects: equals and hashCode

As we have seen, the [==] operator applied to two references indicates whether they refer to the same object, and the [equals] method, inherited from [Object], does the same. For two distinct objects with the same content to be considered equal, a class must redefine the method [equals], and, necessarily along with it, the method [hashCode]. The program [Classes15.java] demonstrates this:

// equality of two objects: equals and hashCode
void main() {
    Person p1 = new Person("paul", 10);
    Person p2 = new Person("paul", 10);
    Person p3 = new Person("nicole", 30);
    // == compares the references, equals compares the values
    IO.println("p1 == p2 : " + (p1 == p2) + ", p1.equals(p2) : " + p1.equals(p2)
            + ", p1.equals(p3) : " + p1.equals(p3));
    // two equal objects have the same hash code
    IO.println("p1.hashCode() == p2.hashCode() : " + (p1.hashCode() == p2.hashCode()));
    // a set (Set) does not contain two equal objects
    Set<Person> persons = new HashSet<>();
    persons.add(p1);
    persons.add(p2);
    persons.add(p3);
    IO.println("size of the set: " + persons.size());
    IO.println("the set contains (paul,10): " + persons.contains(new Person("paul", 10)));
}

class Person {
    // fields
    private final String name;
    private final int age;

    // constructor
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    // two persons are equal if they have the same name and age
    @Override
    public boolean equals(Object o) {
        // same object?
        if (this == o) {
            return true;
        }
        // is o a Person (and not null)?
        if (!(o instanceof Person other)) {
            return false;
        }
        // comparison of the fields
        return age == other.age && Objects.equals(name, other.name);
    }

    // hash code consistent with equals
    @Override
    public int hashCode() {
        return Objects.hash(name, age);
    }

    @Override
    public String toString() {
        return "Person(" + name + "," + age + ")";
    }
}
  • lines 32–44: The method [equals] overrides that of the class [Object]. Its parameter is of type [Object]: a person can be compared to any object. The method follows a standard pattern:
    • lines 35–37: an object is equal to itself;
    • lines 39–41: the expression [o instanceof Person other] (Java 16) is true if [o] refers to an object of type [Person] (or a derived class), and false otherwise, in particular if [o] is equal to [null]. When it is true, the variable [other], of type [Person], refers to the object [o]: its fields can be used. This “type pattern” is explained in detail at the end of the chapter;
    • line 43: the fields are compared. The method [Objects.equals(a, b)] compares two objects with [equals] by handling the case where one of them is [null];
  • lines 47–50: The method [hashCode] returns an integer calculated from the same fields, in this case by the method [Objects.hash]. The rule is mandatory: two objects that are equal in the sense of [equals] must have the same hash code. The dictionaries and sets in JDK (next chapter) sort objects based on this code and would no longer function if the rule were not followed;
  • lines 12–17: A [HashSet] set never contains two equal objects: adding [p2], which is equal to [p1], has no effect, and the search for a new person ([paul, 10]) is successful.

Results:

1
2
3
4
5
> java Classes15.java
p1 == p2 : false, p1.equals(p2) : true, p1.equals(p3) : false
p1.hashCode() == p2.hashCode() : true
size of the set: 2
the set contains (paul,10): true

Typing [equals] and [hashCode] by hand is tedious and prone to errors; development environments can generate them. Records, on the other hand, provide them automatically.

4.5.3. Immutable Objects

The program [Classes16.java] demonstrates two ways to define immutable data:

// immutable objects: an immutable class and a record
void main() {
    Point a = new Point(1, 2);
    // a.x = 5;   // rejected: the x field is final
    Point b = a.move(3, 4);
    IO.println("a=" + a + ", b=" + b);

    // a record: constructor, accessors, toString, equals and hashCode are generated
    Color red = new Color(255, 0, 0);
    Color red2 = new Color(255, 0, 0);
    IO.println("red=" + red + ", r=" + red.r());
    IO.println("red.equals(red2) : " + red.equals(red2) + ", red == red2 : " + (red == red2));
    // a modified copy, created by hand-written methods
    Color pink = red.withG(128).withB(128);
    IO.println("red=" + red + ", pink=" + pink);
}

// an immutable class: final, private and final fields, no setter
final class Point {
    private final int x;
    private final int y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public int getX() {
        return x;
    }

    public int getY() {
        return y;
    }

    // we do not modify the point: we return a new one
    public Point move(int dx, int dy) {
        return new Point(x + dx, y + dy);
    }

    @Override
    public String toString() {
        return "(" + x + "," + y + ")";
    }
}

// a record: an immutable class declared in one line... or almost
record Color(int r, int g, int b) {
    // methods that return a modified copy of the record
    public Color withG(int g) {
        return new Color(r, g, b);
    }

    public Color withB(int b) {
        return new Color(r, g, b);
    }
}
  • lines 19–45: a manually written immutable class. It is final, so that no derived class can add mutable state to it; its fields are private and [final]; it has no modifiers. To “move” a point, the method [move] returns a new point (lines 37–39);
  • Line 48: a record (Java 16). The header [record Color(int r, int g, int b)] declares a final class with three private fields—[final], known as the record components—a three-parameter constructor, and three accessors: [r()], [g()], [b()], and methods [toString], [equals], and [hashCode], all of which use the components;
  • Lines 50–56: You can add methods to a record. Since a record is immutable, it cannot be modified: instead, a copy is created in which one component has been changed. These methods are called “withers” (from the English “with”); other languages provide an expression for this purpose, [with], which does not exist in Java 25;
  • line 11: An accessor for a component is named after the component, without the prefix [get];
  • line 12: two records of the same components are equal in the sense of [equals], even though they are distinct;
  • line 14: the “wither” methods are chained together.

Results:

1
2
3
4
5
> java Classes16.java
a=(1,2), b=(4,6)
red=Color[r=255, g=0, b=0], r=255
red.equals(red2) : true, red == red2 : false
red=Color[r=255, g=0, b=0], pink=Color[r=255, g=128, b=128]

The generated [toString] displays the record name and its components (line 3 of the results). Note here that a record is the most concise way to declare a small, immutable piece of data: this is the type we will use for the tax bracket tiers in this chapter’s example application. Records are discussed in detail later.

4.6. Interfaces

4.6.1. A First Example

An interface is a set of method declarations that forms a contract. A class that chooses to implement an interface commits to providing an implementation of all the abstract methods defined in the interface. The compiler verifies this implementation.

For example, here is the (simplified) definition of the [java.util.Iterator<E>] interface of JDK, which allows you to iterate over the elements of a collection: [public interface Iterator<E> { boolean hasNext(); E next(); }]. The methods of the interface are defined only by their signatures, followed by a semicolon: they have no code and are implicitly public and abstract. It is the classes that implement the interface that provide code for its methods. A class named [C] declares that it implements the interface using the keyword [implements]: [public class C implements Iterator<String>], and it must then define the two public methods [public boolean hasNext() { ... }] and [public String next() { ... }].

A common convention (though not universal—JDK does not follow it) is to start an interface name with the letter I. The following examples make up the [Statistics] folder. Consider the following interface, [IStats.java]:

1
2
3
4
5
6
7
public interface IStats {
    // average of a series of values
    double average();

    // standard deviation of the series
    double standardDeviation();
}

The [IStats] interface contains:

  • a method [average]: to calculate the mean of a series of values;
  • a method [standardDeviation]: to calculate the standard deviation.

Note that it is not specified anywhere which set of values is being referred to. It could be the average grades of a class, the monthly average sales of a particular product, the average temperature in a given location… This is the principle behind interfaces: we assume the existence of methods in the object but not that of specific data. The methods do not have the prefix [get]: they are calculations rather than field accesses, and this choice will allow a record to implement the interface.

A first class implementing the [IStats] interface could be a class used to store the grades of students in a class for a given subject. A student is characterized by the following [Student] record, [Student.java]:

1
2
3
// a student
public record Student(String firstName, String lastName) {
}

The student is identified by their first and last name. The record shown in the previous paragraph does this in a single line. A grade is represented by the following record, [Grade], which follows [Grade.java]:

1
2
3
4
5
6
7
// the grade of a student
public record Grade(Student student, double value) {
    @Override
    public String toString() {
        return "[" + student.firstName() + "," + student.lastName() + "," + value + "]";
    }
}

The grade is defined by the student being graded and the grade itself. The record redefines the method [toString] generated by the compiler to display a grade in the form [firstName,lastName,value].

The grades of all students in a given subject are grouped in the following class [ArrayOfGrades]: [ArrayOfGrades.java]:

import java.util.Arrays;

public class ArrayOfGrades implements IStats {
    // fields
    private final String subject;
    private final Grade[] grades;
    private final double average;
    private final double standardDeviation;

    // constructor
    public ArrayOfGrades(String subject, Grade[] grades) {
        // storing the data
        this.subject = subject;
        this.grades = grades;
        // computing the average of the grades
        double sum = 0;
        for (Grade grade : grades) {
            sum += grade.value();
        }
        average = grades.length != 0 ? sum / grades.length : -1;
        // computing the standard deviation
        double squares = 0;
        for (Grade grade : grades) {
            squares += Math.pow(grade.value() - average, 2);
        }
        standardDeviation = grades.length != 0 ? Math.sqrt(squares / grades.length) : -1;
    }

    // getters
    public String getSubject() {
        return subject;
    }

    public Grade[] getGrades() {
        return grades;
    }

    // implementation of the methods of the IStats interface
    @Override
    public double average() {
        return average;
    }

    @Override
    public double standardDeviation() {
        return standardDeviation;
    }

    // toString
    @Override
    public String toString() {
        return "subject=" + subject + ", grades=" + Arrays.toString(grades);
    }
}
  • Line 3: The class [ArrayOfGrades] implements the interface [IStats]. It must therefore implement the methods [average] and [standardDeviation]. These are implemented on lines 39–47. The annotation [@Override] is also used for implementing an interface method;
  • lines 5–8: four fields [final]: the subject for which the object stores grades, the grade array, the average, and the standard deviation of the grades;
  • lines 11–27: the constructor. Here, we assume that the grades passed to the constructor will not change afterward. Therefore, we use the constructor to immediately calculate the mean and standard deviation of these grades and store them. A field such as [final], like [average], can be assigned a value in the constructor (line 20); it cannot be assigned a value afterward. The expressions [condition ? value1 : value2] set the mean and standard deviation of an empty array to -1. [Math.pow(x, 2)] calculates x², and [Math.sqrt] calculates the square root;
  • lines 50–53: the method [toString] uses the static method [Arrays.toString], which returns the elements of an array in square brackets, separated by commas, each converted to a string by its method [toString], in this case, the one for record [Grade].

In the previous class, grades were stored in an array. It was not possible to add a new grade after the [ArrayOfGrades] object was created. We now propose a second implementation of the [IStats] interface, called [ListOfGrades], where this time the notes are stored in a [List<Grade>] list, with the ability to add notes after the initial creation of the [ListOfGrades] object: [ListOfGrades.java]:

import java.util.List;

public class ListOfGrades implements IStats {
    // fields
    private final String subject;
    private final List<Grade> grades;
    // stored average and standard deviation (null: not yet computed)
    private Double average;
    private Double standardDeviation;

    // constructor
    public ListOfGrades(String subject, List<Grade> grades) {
        this.subject = subject;
        this.grades = grades;
    }

    // getters
    public String getSubject() {
        return subject;
    }

    public List<Grade> getGrades() {
        return grades;
    }

    // adding a grade
    public void add(Grade grade) {
        grades.add(grade);
        // the average and the standard deviation will have to be recomputed
        average = null;
        standardDeviation = null;
    }

    // average of the grades: computed on the first request
    @Override
    public double average() {
        if (average == null) {
            average = computeAverage();
        }
        return average;
    }

    // standard deviation of the grades: computed on the first request
    @Override
    public double standardDeviation() {
        if (standardDeviation == null) {
            standardDeviation = computeStandardDeviation();
        }
        return standardDeviation;
    }

    private double computeAverage() {
        if (grades.isEmpty()) {
            return -1;
        }
        double sum = 0;
        for (Grade grade : grades) {
            sum += grade.value();
        }
        return sum / grades.size();
    }

    private double computeStandardDeviation() {
        if (grades.isEmpty()) {
            return -1;
        }
        double m = average();
        double squares = 0;
        for (Grade grade : grades) {
            squares += Math.pow(grade.value() - m, 2);
        }
        return Math.sqrt(squares / grades.size());
    }

    // toString
    @Override
    public String toString() {
        return "subject=" + subject + ", grades=" + grades;
    }
}
  • line 3: the class [ListOfGrades] implements the interface [IStats];
  • line 6: Notes are now stored in a list rather than an array;
  • lines 27–32: You can now add a grade to those already stored. The mean and standard deviation are no longer calculated in the constructor but on demand, within the interface methods themselves: [average] (lines 35–41) and [standardDeviation] (lines 44–50). Once calculated, these values are stored so they are not recalculated with every request: they are said to be cached. Adding a score invalidates the cache;
  • lines 8–9: the cache. The type [Double] is the wrapper class for the primitive type [double]: unlike a [double] variable, a [Double] variable can have the value [null], which here means “not yet calculated.” Uninitialized fields are set to [null] when the object is constructed;
  • lines 38 and 40: the compiler automatically converts the [double] returned by [computeAverage] into the object [Double] (“autoboxing”), and the object [Double] into [double] for the method’s return value (“unboxing”).

A test program might look like the following [Main.java]:

void main() {
    // a few students and their English grades
    Student[] students = {
        new Student("Paul", "Martin"), new Student("Maxime", "Germain"), new Student("Berthine", "Samin")
    };
    Grade[] grades = { new Grade(students[0], 14), new Grade(students[1], 16), new Grade(students[2], 18) };
    // which are stored in an ArrayOfGrades object
    ArrayOfGrades english = new ArrayOfGrades("english", grades);
    // display of average and standard deviation
    displayStats(english);
    // the same grades in a ListOfGrades object
    ListOfGrades french = new ListOfGrades("french", new ArrayList<>(List.of(grades)));
    displayStats(french);
    // we add a grade
    french.add(new Grade(new Student("Jérôme", "Jaric"), 10));
    displayStats(french);
    // a variable can have an interface as its type
    IStats stats = english;
    IO.println("English average: " + stats.average());
    stats = french;
    IO.println("French average: " + stats.average());
}

// display of the average and standard deviation of an object implementing IStats
void displayStats(IStats values) {
    IO.println(values + ", Average=" + values.average() + ", Standard deviation=" + values.standardDeviation());
}
  • lines 3–5: creation of an array of students;
  • line 6: creation of an array of grades;
  • line 8: a [ArrayOfGrades] object whose mean and standard deviation are displayed on line 10;
  • line 12: an object [ListOfGrades], whose mean and standard deviation are displayed on line 13. [List.of(grades)] returns a read-only list of the array’s elements; the constructor of the [ArrayList] class turns it into a modifiable list to which notes can be added;
  • line 15: a new note is added, followed by a refresh of the display;
  • Lines 25–27: The method [displayStats] takes a *[IStats] type as a parameter, which is an interface type. This means that the actual parameter can be any object that implements the *[IStats] interface. When using a data object of an interface type, only the methods of the interface that are implemented by the data object are used. The rest are ignored. This property is similar to the polymorphism discussed for classes. If a set of classes Ci that are not related to one another through inheritance (and therefore cannot use inheritance-based polymorphism) share a set of methods with the same signature, it may be useful to group these methods into an interface I that all the relevant classes would implement. Instances of these classes Ci can then be used as actual parameters for methods that accept a formal parameter of type I—that is, methods that use only the methods of the Ci objects defined in the interface I, and not the specific fields and methods of the various Ci classes;
  • lines 18–21: A variable can be of the type of an interface. It can then refer to any object of a class that implements the interface. The compiler allows access only to the methods of the interface (and to those of the class [Object], such as [toString]).

Results:

1
2
3
4
5
6
> java Main.java
subject=english, grades=[[Paul,Martin,14.0], [Maxime,Germain,16.0], [Berthine,Samin,18.0]], Average=16.0, Standard deviation=1.632993161855452
subject=french, grades=[[Paul,Martin,14.0], [Maxime,Germain,16.0], [Berthine,Samin,18.0]], Average=16.0, Standard deviation=1.632993161855452
subject=french, grades=[[Paul,Martin,14.0], [Maxime,Germain,16.0], [Berthine,Samin,18.0], [Jérôme,Jaric,10.0]], Average=14.5, Standard deviation=2.958039891549808
English average: 16.0
French average: 14.5

Concatenating a number ([double]) with a string uses the [Double.toString] method, which always writes the number with a decimal point and at least one decimal place (16.0), regardless of the workstation’s language settings. For a French-style display, use [String.format("%.2f", x)], as in the following example.

Finally, note that interfaces can be implemented multiple times; that is, one can write [public class DerivedClass extends BaseClass implements I1, I2, ..., In], where the Ij are interfaces: a class inherits from only one class but can implement as many interfaces as it wishes. An interface can itself extend one or more other interfaces: [interface I3 extends I1, I2].

4.6.2. Default, static, and private interface methods

Since Java 8, an interface can provide a default implementation for some of its methods, declared with the keyword [default]. A class that implements the interface can then use this implementation or provide its own. This makes it possible, in particular, to add a method to an already published interface without requiring all classes that implement it to be modified: this is how the collections in the JDK have been expanded over successive versions. An interface can also contain static methods (Java 8) and private methods (Java 9). The program [Classes17.java] provides an example:

// default (Java 8), static (Java 8) and private (Java 9) interface methods
void main() {
    IShape[] shapes = { new Square(2), new Circle(1), IShape.unitSquare() };
    for (IShape shape : shapes) {
        IO.println(shape.description());
    }
    // the default method is inherited by the Square class
    Square square = new Square(3);
    IO.println(square.description());
}

interface IShape {
    // abstract method: to be implemented
    double area();

    // method with a default implementation
    default String description() {
        return getClass().getSimpleName() + " with area " + format(area());
    }

    // static method: it is called with the name of the interface
    static IShape unitSquare() {
        return new Square(1);
    }

    // private method: usable only by the methods of the interface
    private String format(double value) {
        return String.format("%.2f", value);
    }
}

// Square uses the default implementation of description
// (static class because the interface creates squares: see Classes10)
static class Square implements IShape {
    private final double side;

    public Square(double side) {
        this.side = side;
    }

    @Override
    public double area() {
        return side * side;
    }
}

// Circle provides its own implementation of description
class Circle implements IShape {
    private final double radius;

    public Circle(double radius) {
        this.radius = radius;
    }

    @Override
    public double area() {
        return Math.PI * radius * radius;
    }

    @Override
    public String description() {
        return String.format("Circle with radius %s and area %.2f", radius, area());
    }
}
  • lines 12–30: the [IShape] interface:
    • line 14: an abstract method [area], to be implemented;
    • lines 17–19: a method [description] with a default implementation. This method can use the other methods of the interface ([area], [format]) and those of [Object] ([getClass]);
    • lines 22–24: a static method, which is called using the interface name: [IShape.unitSquare()] (line 3). This is a static factory method;
    • lines 27–29: a private method, accessible only to the methods of the interface. [String.format("%.2f", value)] formats the number to two decimal places, following the conventions of the system’s locale—in this case, the French decimal comma;
  • lines 34–45: the class [Square] implements only [area]; it uses the method [description] by default. It is declared as [static] because the static method [unitSquare] of the interface creates squares (see [Classes10.java]);
  • lines 48–64: The class [Circle] provides its own method, [description]. The format [%s] inserts the radius converted to a string by [Double.toString] (“1.0”), and the format [%.2f] inserts the area with two decimal places;
  • Line 9: The default method is inherited by the [Square] class just like any other method: it can be called on a variable of type [Square].

Results:

1
2
3
4
5
> java Classes17.java
Square with area 4,00
Circle with radius 1.0 and area 3,14
Square with area 1,00
Square with area 9,00

A class can implement two interfaces, each of which provides a default method with the same signature: the compiler then requires the class to redefine the method, even if it means calling one of the two versions via [I1.super.method()]. Unlike an abstract class, an interface has no state: it cannot declare instance fields, only constants ([static final], implicitly).

4.7. Abstract classes

An abstract class is a class that cannot be instantiated. You must create derived classes, which can be instantiated. You can use abstract classes to refactor the code of a class hierarchy. Let's examine the following example, which forms the folder [Users] [User.java]:

public abstract class User {
    // fields
    private final String login;
    private final String password;
    private final String role;

    // constructor
    @SuppressWarnings("this-escape")
    protected User(String login, String password) throws UnknownUserException {
        // we store the information
        this.login = login;
        this.password = password;
        // we identify the user
        String role = identify();
        // identified?
        if (role == null) {
            throw new UnknownUserException("[" + login + "," + password + "]");
        }
        this.role = role;
    }

    // toString
    @Override
    public String toString() {
        return "User[" + login + "," + password + "," + role + "]";
    }

    // identification: returns the role of the user or null if they are not recognized
    protected abstract String identify();
}
  • line 1: the class is declared abstract using the keyword [abstract];
  • Lines 9–20: the constructor for the class [User]. This class stores information about a web application’s user. The application has various types of users authenticated by a username and password (lines 3–4). These two pieces of information are verified against a directory (LDAP) for some users, against a database for others, and so on. The constructor is [protected]: it is intended for the constructors of derived classes;
  • lines 11–12: the authentication information is stored;
  • line 14: they are verified by a method [identify]. Because the identification method is not known at this level, it is declared abstract (line 29) with the keyword [abstract]. An abstract method has no body: derived classes will provide one. A class that has an abstract method must itself be declared abstract. The method [identify] returns a string specifying the user’s role (basically, what they are authorized to do), or [null] if the user is not recognized;
  • lines 16–18: if the user is not recognized, an exception is thrown. The [UnknownUserException] exception is a checked exception (see below): the constructor must declare it using the [throws UnknownUserException] clause (line 9);
  • lines 23–26: the method [toString], which identifies an instance of the class.

Here, we assume that the developer wants to control the creation of instances of the class [User] and its derived classes, perhaps because they want to ensure that an exception of a certain type is thrown if the user is not recognized. Derived classes can rely on this constructor. To do so, they must provide the [identify] method. Note that the constructor here calls a method overridden by the derived classes even though those classes have not yet executed their own constructors: the [identify] method must therefore not use any fields of the derived class, as they have not yet been initialized. This is a practice that should be used with caution, and the compiler flags it when compiling with the [-Xlint:all] option, which enables all warnings (here without the annotation on line 8, hence the line number 13 in the message):

1
2
3
4
5
> javac -Xlint:all -d classes *.java
User.java:13: warning: [this-escape] possible 'this' escape before subclass is fully initialized
        String role = identify();
                              ^
1 warning

The warning [this-escape] (Java 21) indicates that the object being constructed ([this]) “escapes” the constructor before being fully initialized. In this case, this is intentional and harmless, since the [identify] methods of the derived classes do not use any fields. After verifying this, the warning is suppressed—for this constructor only—using the annotation [@SuppressWarnings("this-escape")] (line 8). The source code launcher ([java Main.java]) does not enable these warnings: it is a good idea to compile your programs regularly with [javac -Xlint:all], which is what we have done for all the programs in this course.

The [UnknownUserException] class is as follows:

1
2
3
4
5
6
7
8
public class UnknownUserException extends Exception {
    // version number of the class (see the explanations)
    private static final long serialVersionUID = 1L;

    public UnknownUserException(String message) {
        super(message);
    }
}
  • Line 1: It derives from the class [Exception]. It is therefore a checked exception: the compiler verifies that any method that can throw it declares it using a [throws] clause, and that any code that calls such a method either catches it or declares it in turn. An exception derived from [RuntimeException], such as [IllegalArgumentException], is not a checked exception;
  • line 3: Exceptions are “serializable” objects (convertible to a sequence of bytes); the compiler, with the [-Xlint:all] option, requires that a serializable class declare a version number [serialVersionUID]. We simply use the value 1;
  • Lines 5–7: It has only a single constructor that takes an error message as a parameter. This is passed to the parent class, which has the same constructor.

We now derive the child class [Administrator] from the class [User]:

public class Administrator extends User {
    // constructor
    public Administrator(String login, String password) throws UnknownUserException {
        super(login, password);
    }

    // identification
    @Override
    protected String identify() {
        // LDAP identification
        // ...
        return "admin";
    }
}
  • lines 3–5: The constructor simply passes the parameters it receives to its parent class. It also declares the exception that the parent constructor may throw;
  • lines 8–13: the [identify] method of the [Administrator] class. We assume that an administrator is identified by a directory LDAP. This method implements the abstract method [identify] of its parent class. It retains the access level [protected] of the method it overrides (an overridden method can expand access, but not restrict it).

The classes [Observer] and [Unknown] also derive from the class [User]. They differ from class [Administrator] only in their method [identify]. The method in class [Observer] assumes that an observer is identified by a database and returns the “observer” role:

1
2
3
4
5
6
7
    // identification
    @Override
    protected String identify() {
        // identification through a database
        // ...
        return "observer";
    }

The method for class [Unknown] returns the reference [null] to indicate that the user was not recognized:

1
2
3
4
5
6
7
    // identification
    @Override
    protected String identify() {
        // user not known
        // ...
        return null;
    }

Ultimately, the objects [Administrator], [Observer], and [Unknown] are initialized by the same constructor—that of the parent class [User]. This constructor uses the [identify] method provided by these classes. A test program for [Main.java] might look like this:

void main() {
    try {
        IO.println(new Observer("observer", "pwd1"));
        IO.println(new Administrator("admin", "pwd2"));
        IO.println(new Unknown("xx", "yy"));
    } catch (UnknownUserException e) {
        IO.println("Unknown user: " + e.getMessage());
    }
    // User u = new User("x", "y");   // rejected: the User class is abstract
}

Note that in lines 3 and 4, the method [User.toString()] is used by the method *[println]. On line 5, the constructor throws a [UnknownUserException] exception, which is caught on line 6: the object is not created, and nothing is displayed by [println]. The three object creations are placed within a [try] block, because the compiler requires that the checked exception be caught. Line 9, if uncommented, is rejected by the compiler ([User is abstract; cannot be instantiated]*): you cannot create an object of an abstract class.

Results:

1
2
3
4
> java Main.java
User[observer,pwd1,observer]
User[admin,pwd2,admin]
Unknown user: [xx,yy]

An abstract class is similar to an interface: both declare methods that other classes must implement. However, an abstract class can have fields, constructors, and code common to its derived classes; conversely, a class can derive from only one abstract class, whereas it can implement multiple interfaces. The two are often combined: an interface defines the contract, and an abstract class that implements it factors out the code common to multiple implementations. This is what the example application in this chapter will do.

4.8. Generic Classes, Interfaces, and Methods

4.8.1. Generic Classes and Methods

Suppose we want to write a class [IntPair] that stores two integers and can swap them: two fields, [int first] and [int second], and a method, [swap()], that swaps their values using a temporary variable, [int temp]. If we then wanted a pair of strings, followed by a pair of people, we would write two more classes, [StringPair] and [PersonPair], identical to the previous one except for the type: [String] or [Person] instead of [int].

Generic classes, interfaces, and methods address the need for code that differs only in the type of certain data. They were introduced with Java 5 (2004): prior to that, the type [Object] was used, which accepted anything, at the cost of explicit type conversions and errors detected only at runtime. Note that Java does not support passing parameters by reference: a method cannot swap the values of two variables from the caller, but it can swap two elements of an object, a pair, or an array.

The program [Classes18.java] features a generic class, a generic method, and a second generic class:

// generic classes and methods
void main() {
    // --- a generic class Pair<T>
    // Integer
    Pair<Integer> pi = new Pair<>(1, 2);
    pi.swap();
    IO.println("pi=" + pi);
    // String
    Pair<String> ps = new Pair<>("s1", "s2");
    ps.swap();
    IO.println("ps=" + ps);
    // Person
    Person p1 = new Person("jean", "clu", 20), p2 = new Person("pauline", "dard", 55);
    Pair<Person> pp = new Pair<>(p1, p2);
    pp.swap();
    IO.println("pp=" + pp);
    // the first element is of type Person: no conversion
    Person first = pp.getFirst();
    IO.println("first element: " + first);

    // --- a generic method Generic2.swap<T>
    Integer[] integers = { 1, 2, 3 };
    Generic2.<Integer>swap(integers, 0, 2);
    IO.println("integers=" + Arrays.toString(integers));
    // the compiler deduces T from the type of the actual parameters
    Person[] persons = { p1, p2 };
    Generic2.swap(persons, 0, 1);
    IO.println("persons=" + Arrays.toString(persons));

    // --- a generic class with a state: a stack of elements of type T
    MyStack<Person> stack = new MyStack<>();
    stack.push(p1);
    stack.push(p2);
    IO.println("The stack contains " + stack.size() + " persons");
    Person top = stack.pop();
    IO.println("Popped person: " + top + ", " + stack.size() + " left");
    // stack.push("x");   // rejected: the stack only accepts Person objects
}

// a generic class: a pair of two elements of type T
class Pair<T> {
    private T first;
    private T second;

    public Pair(T first, T second) {
        this.first = first;
        this.second = second;
    }

    public T getFirst() {
        return first;
    }

    public T getSecond() {
        return second;
    }

    // the two elements are swapped
    public void swap() {
        T temp = first;
        first = second;
        second = temp;
    }

    @Override
    public String toString() {
        return "(" + first + "," + second + ")";
    }
}

// a non-generic class with a generic method
class Generic2 {
    // swaps elements no. i and j of an array of elements of type T
    public static <T> void swap(T[] array, int i, int j) {
        T temp = array[i];
        array[i] = array[j];
        array[j] = temp;
    }
}

// a stack of elements of type T
class MyStack<T> {
    // the elements of the stack
    private final List<T> elements = new ArrayList<>();

    // number of elements
    public int size() {
        return elements.size();
    }

    // adding an element at the top of the stack
    public void push(T element) {
        elements.add(element);
    }

    // removing the element at the top of the stack
    public T pop() {
        if (elements.isEmpty()) {
            throw new IllegalStateException("empty stack");
        }
        return elements.removeLast();
    }
}

// a person
record Person(String firstName, String lastName, int age) {
    @Override
    public String toString() {
        return "[" + firstName + ", " + lastName + ", " + age + "]";
    }
}
  • lines 41–69: The class [Pair] is parameterized by a type denoted as *[T], enclosed in angle brackets after the class name. You can name it whatever you like; by convention, it is an uppercase letter ([T] for type, [E] for element, [K] and [V] for key and value). This type, [T], is then used in the class just like any other type: for the two fields, the constructor parameters, the results of the accessors, and the temporary variable [temp]* (line 60). The class [Pair] is said to be a generic class;
  • Line 5: When using a generic class parameterized by the types T1, T2, …, these types must be specified. Here, [Pair<Integer>] is a pair of [Integer] objects. A generic parameter can only be a reference type: we write [Pair<Integer>], not [Pair<int>]. The integers 1 and 2 are automatically converted to [Integer] objects (“autoboxing”). On the right, the “diamond” operator [new Pair<>(...)] allows the compiler to infer the type [Integer] from that of the variable. Lines 9 and 14: the same applies to the types [String] and [Person];
  • Line 18: The method [getFirst] of a [Pair<Person>] returns an object of type [Person], without conversion;
  • lines 72–79: the class [Generic2] is not generic, but its static method [swap] is: the parameter of type [<T>] is declared before the return type (line 74). The method swaps elements i and j of an array of elements of type [T];
  • line 23: the method [swap] is called, specifying the type *[T] between ***[<>]. In fact, the compiler is able to infer [T] from the type of the actual parameters. Therefore, the notation in line 27 is valid, and this is the one used in practice;
  • lines 82–103: a generic class with a state: a stack of elements of type [T]. Elements are pushed onto the top of the stack and popped from the top. The elements are stored in a generic list of type [List<T>] (line 84), which is itself parameterized by the [T] type of the stack. The method [removeLast] (Java 21) retrieves and returns the last element of the list. An empty stack throws a [IllegalStateException] exception, the exception defined by Java for an operation requested from an object that is not in the expected state;
  • line 31: a stack of people. The compiler guarantees that it will contain only people: the statement on line 37 would be rejected. The method [pop] returns an object of type [Person], without conversion;
  • lines 106–111: The person is a record for which the method [toString] has been redefined.

Results:

1
2
3
4
5
6
7
8
9
> java Classes18.java
pi=(2,1)
ps=(s2,s1)
pp=([pauline, dard, 55],[jean, clu, 20])
first element: [pauline, dard, 55]
integers=[3, 2, 1]
persons=[[pauline, dard, 55], [jean, clu, 20]]
The stack contains 2 persons
Popped person: [pauline, dard, 55], 1 left

4.8.2. Generic parameter bounds

In the [Pair<T>] class, the compiler knows nothing about the [T] type: it allows only the methods of the [Object] class to be called on objects of type [T]. To be able to use other methods, we constrain the generic parameter with the keyword [extends]:

Constraint
Meaning
[<T extends BaseClass>]
T must be the specified class or a derived class
[<T extends Interface>]
T must implement the specified interface (written as [extends], not [implements])
[<T extends BaseClass & I1 & I2>]
Multiple parameters: at most one class, listed first, followed by interfaces
[<T extends Comparable<T>>]
A bound that uses T itself: T must be comparable to T

There is no constraint requiring that T be a reference type (it always is) or that it have a parameterless constructor: you cannot write [new T()] in a generic class (see type erasure below). The program [Classes19.java] uses a constraint:

// the bounds of generic parameters
void main() {
    // --- T is always an object type: int is replaced by Integer
    Pair<Integer> pi = new Pair<>(1, 2);
    // Pair<int> pe = new Pair<>(1, 2);   // error: int is a primitive type
    IO.println("pi=" + pi);

    // --- the bound T extends IValue
    // ordering pairs of objects of type RandomValue
    for (int i = 0; i < 5; i++) {
        Pair<RandomValue> pair = new Pair<>(new RandomValue(), new RandomValue());
        IO.print("Before --> " + pair);
        Generic4.order(pair);
        IO.println("   After --> " + pair);
    }
    // ordering a pair of objects of type StringValue
    Pair<StringValue> strings = new Pair<>(new StringValue("xxxxxxxxxxxxxx"), new StringValue("xx"));
    IO.println("Before --> " + strings);
    Generic4.order(strings);
    IO.println("After --> " + strings);
    // Generic4.order(pi);   // error: Integer does not implement IValue
}

// a pair of two elements of type T
class Pair<T> {
    private T first;
    private T second;

    public Pair(T first, T second) {
        this.first = first;
        this.second = second;
    }

    public T getFirst() {
        return first;
    }

    public T getSecond() {
        return second;
    }

    public void swap() {
        T temp = first;
        first = second;
        second = temp;
    }

    @Override
    public String toString() {
        return "(" + first + "," + second + ")";
    }
}

// the type T must implement IValue
class Generic4 {
    public static <T extends IValue> void order(Pair<T> pair) {
        // if the 1st element is greater than the 2nd, the elements are swapped
        if (pair.getFirst().value() > pair.getSecond().value()) {
            pair.swap();
        }
    }
}

// an object having an integer value
interface IValue {
    int value();
}

// an object having a random value
class RandomValue implements IValue {
    // random number generator shared by all instances
    // (initialized with a fixed seed so that the results are reproducible)
    private static final Random generator = new Random(2026);
    // value of the object
    private final int value = generator.nextInt(100);

    @Override
    public int value() {
        return value;
    }

    @Override
    public String toString() {
        return String.valueOf(value);
    }
}

// an object whose value is the length of a string
class StringValue implements IValue {
    private final String s;

    public StringValue(String s) {
        this.s = s;
    }

    @Override
    public int value() {
        return s.length();
    }

    @Override
    public String toString() {
        return s;
    }
}
  • line 5: if you uncomment this line, the compiler reports an error because the type *[int]* is a primitive type;
  • Lines 55–62: The generic method [order] requires that the type [T] implement the interface [IValue] (lines 65–67). This interface has a method named [value], used on line 58, which returns the value of the object of type [T]. Without the bound, the compiler would reject the call to [pair.getFirst().value()]: any [T] type does not have a [value] method. The two elements of the pair are swapped only if the value of the first is greater than that of the second: the method [order] sorts the two elements in ascending order by value;
  • lines 70–86: The class [RandomValue] implements the interface [IValue]. The value of an instance is a random number between 0 and 99 (line 75), provided by a random number generator in the [Random] class shared by all instances (line 73). This generator is initialized with a fixed “seed”: it therefore always produces the same sequence of numbers, making the results reproducible. If reproducibility is not required, the generator created by [new Random()] is used; for security purposes, the class [SecureRandom] is used instead;
  • lines 89–105: The class [StringValue] also implements the interface [IValue]: the value of an instance is the length of the character string passed to the constructor;
  • lines 10–15: Pairs of objects of type [RandomValue] are ordered;
  • lines 17–20: a pair of objects of type [StringValue] is ordered;
  • line 21: if this line is uncommented, the compiler reports that the type [Integer] does not satisfy the bound.

Results:

1
2
3
4
5
6
7
8
9
> java Classes19.java
pi=(1,2)
Before --> (99,50)   After --> (50,99)
Before --> (97,30)   After --> (30,97)
Before --> (49,38)   After --> (38,49)
Before --> (73,36)   After --> (36,73)
Before --> (34,78)   After --> (34,78)
Before --> (xxxxxxxxxxxxxx,xx)
After --> (xx,xxxxxxxxxxxxxx)

Here are the errors obtained when running the program after lines 5 and 21 have been uncommented:

> java Classes19.java
Classes19.java:5: error: unexpected type
    Pair<int> pe = new Pair<>(1, 2);   // error: int is a primitive type
         ^
  required: reference
  found:    int
Classes19.java:21: error: method order in class Classes19.Generic4 cannot be applied to given types;
    Generic4.order(pi);   // error: Integer does not implement IValue
            ^
  required: Classes19.Pair<T>
  found:    Classes19.Pair<Integer>
  reason: inference variable T has incompatible bounds
    equality constraints: Integer
    upper bounds: IValue
  where T is a type-variable:
    T extends IValue declared in method <T>order(Classes19.Pair<T>)
2 errors
error: compilation failed

The second message is typical of errors involving generics: the compiler attempted to infer the type [T]; the pair constraint requires [T] = [Integer] (“equality constraints”), the bound constraint requires that [T] implements [IValue] (“upper bounds”); the two constraints are incompatible.

4.8.3. Generic Interfaces: Comparable<T> and Comparator<T>

To illustrate the concept of a generic interface, we will sort an array of people according to different criteria. The method that allows us to sort an array is the static method [sort] of the class [java.util.Arrays]. It is overloaded; we will use two variants:

  • [Arrays.sort(Object[] array)] sorts the array according to the natural order of its elements: these elements must implement the [Comparable<T>] interface, whose sole method, [int compareTo(T other)], compares the current object to the [other] object;
  • [Arrays.sort(T[] array, Comparator<? super T> comparator)] sorts the array according to the order defined by a comparator object, which implements the [Comparator<T>] interface, whose [int compare(T x, T y)] method compares two objects (the type [? super T] is explained in the following paragraph).

The methods [compareTo] and [compare] follow the same rule: they return a positive number if x > y, 0 if x == y, and a negative number if x < y. It is up to the developer to define the meaning of the operators <, ==, and >. For example, if [p1] and [p2] are two [Person] objects, we can say that p1 > p2 if the name of [p1] comes after the name of [p2] in alphabetical order. This will result in an ascending sort based on the people’s names. If we want to sort by age, we’ll say that p1 > p2 if the age of [p1] is greater than the age of [p2]. To sort in descending order, simply reverse the signs of the results.

We know enough to sort a table of people. The program [Classes20.java] is as follows:

// the generic interfaces Comparable<T> and Comparator<T>: sorting an array of persons
void main() {
    Person[] persons = {
        new Person("claude", "pollon", 25),
        new Person("valentine", "germain", 35),
        new Person("paul", "germain", 32),
    };
    // display
    display("Array to sort", persons);
    // sort according to the natural order of persons (Comparable): the age
    Arrays.sort(persons);
    display("Array after sorting by age", persons);
    // sort according to last and first names (Comparator)
    Arrays.sort(persons, new CompareNames());
    display("Array after sorting by last and first names", persons);
    // sort by descending age, with an anonymous class
    Arrays.sort(persons, new Comparator<Person>() {
        @Override
        public int compare(Person p1, Person p2) {
            return Integer.compare(p2.getAge(), p1.getAge());
        }
    });
    display("Array after sorting by descending age", persons);
}

// display of an array of persons
void display(String text, Person[] persons) {
    IO.println(text + "-".repeat(50 - text.length()));
    for (Person p : persons) {
        IO.println(p);
    }
}

// a person, comparable to another person
class Person implements Comparable<Person> {
    private final String firstName;
    private final String lastName;
    private final int age;

    public Person(String firstName, String lastName, int age) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.age = age;
    }

    public String getFirstName() {
        return firstName;
    }

    public String getLastName() {
        return lastName;
    }

    public int getAge() {
        return age;
    }

    // the natural order of persons: the order of ages
    @Override
    public int compareTo(Person other) {
        return Integer.compare(age, other.age);
    }

    @Override
    public String toString() {
        return "[" + firstName + ", " + lastName + ", " + age + "]";
    }
}

// comparison of the last and first names of two persons
class CompareNames implements Comparator<Person> {
    // comparison of strings according to the rules of the French language
    private final Collator collator = Collator.getInstance(Locale.FRENCH);

    @Override
    public int compare(Person p1, Person p2) {
        // we compare the last names
        int i = collator.compare(p1.getLastName(), p2.getLastName());
        if (i != 0) {
            return i;
        }
        // equal last names: we compare the first names
        return collator.compare(p1.getFirstName(), p2.getFirstName());
    }
}
  • lines 3–7: the array of people;
  • lines 35–68: the class [Person] implements the generic interface [Comparable<Person>]: its objects are comparable to other people. Its method [compareTo] (lines 59–62) defines the natural order of people, in this case by age. The static method [Integer.compare(a, b)] returns a negative, zero, or positive number depending on whether [a] is less than, equal to, or greater than [b]: this is exactly what the method [compareTo] expects;
  • line 11: sorting in natural order;
  • line 14: sorts the array of people by last name and first name. The second parameter of the [sort] method is an instance of the [CompareNames] class, which implements the generic interface [Comparator<Person>];
  • lines 71–85: the class [CompareNames]. Its method [compare] implements the method [int compare(T x, T y)] of the interface [Comparator<T>], where [T] = [Person]. Strings are not compared using their [compareTo] method, which follows the order of character codes (uppercase letters before lowercase, accented letters after “z”), but rather by a [Collator] object (line 73) that applies the sorting rules for the French language;
  • lines 17–22: sorting by age in descending order. The comparator this time is an instance of an anonymous class: the expression [new Comparator<Person>() { ... }] declares an unnamed class that implements the [Comparator<Person>] interface and immediately creates an instance of that class. The body of the class (lines 18–21) provides the method [compare]. An anonymous class avoids having to declare a class that is used only once;
  • line 28: the [repeat] method (Java 11) pads a string: the title line is padded with dashes up to 50 characters.

Results:

> java Classes20.java
Array to sort-------------------------------------
[claude, pollon, 25]
[valentine, germain, 35]
[paul, germain, 32]
Array after sorting by age------------------------
[claude, pollon, 25]
[paul, germain, 32]
[valentine, germain, 35]
Array after sorting by last and first names-------
[paul, germain, 32]
[valentine, germain, 35]
[claude, pollon, 25]
Array after sorting by descending age-------------
[valentine, germain, 35]
[paul, germain, 32]
[claude, pollon, 25]

We have now encountered three types of classes declared inside another: static nested classes ([static class]), inner classes (without [static], which are associated with an object of the enclosing class), and anonymous classes. In Chapter 7, we’ll see that for an interface with only one abstract method, such as [Comparator], we can use an even shorter alternative than an anonymous class: a lambda, [Arrays.sort(persons, (p1, p2) -> Integer.compare(p2.getAge(), p1.getAge()));], or a method reference, [Arrays.sort(persons, Comparator.comparing(Person::getLastName));].

4.8.4. Wildcards and Type Erasure

A [List<Integer>] is not a [List<Number>], even though [Integer] derives from [Number]: if it were, we could add a [Double] to a list of integers via a variable of type [List<Number>]. A method that accepts a [List<Number>] therefore rejects a [List<Integer>]. To write methods that accept lists of elements of related types, Java uses wildcards:

Type
Meaning
Usage
[List<? extends Number>]
a list of elements of an unknown type derived from [Number]
You can read [Number] from it, but you cannot add elements to it
[List<? super Integer>]
A list of items of an unknown type from which [Integer] is derived ([Integer], [Number], [Object])
[Integer] can be added to this list
[List<?>]
a list of items of an unknown type
You can read [Object]

The program [Classes21.java] illustrates wildcards and a special feature of Java generics: type erasure:

// wildcards, bounds and type erasure
void main() {
    List<Integer> integers = List.of(1, 2, 3);
    List<Double> reals = List.of(1.5, 2.25);
    // ? extends Number: a list of elements of a type derived from Number
    IO.println("Sum of integers: " + sum(integers));
    IO.println("Sum of reals: " + sum(reals));
    // ? super Integer: a list that can receive Integers
    List<Number> numbers = new ArrayList<>();
    addIntegers(numbers, 3);
    numbers.add(2.5);
    IO.println("numbers=" + numbers + ", sum=" + sum(numbers));
    // a recursive bound: T must be comparable to itself
    IO.println("max : " + max(List.of("paul", "jean", "valentine")) + ", " + max(integers));
    // type erasure: at runtime, a list is a list
    List<String> l1 = new ArrayList<>();
    List<Integer> l2 = new ArrayList<>();
    IO.println("same class: " + (l1.getClass() == l2.getClass()) + " (" + l1.getClass().getName() + ")");
}

// sum of the elements of a list of numbers
double sum(List<? extends Number> values) {
    double sum = 0;
    for (Number value : values) {
        sum += value.doubleValue();
    }
    // values.add(1);   // rejected: the exact type of the elements is unknown
    return sum;
}

// adding the integers 1, 2, ..., n to a list able to receive Integers
void addIntegers(List<? super Integer> list, int n) {
    for (int i = 1; i <= n; i++) {
        list.add(i);
    }
}

// the largest element of a non-empty list
<T extends Comparable<? super T>> T max(List<T> elements) {
    T max = elements.getFirst();
    for (T element : elements) {
        if (element.compareTo(max) > 0) {
            max = element;
        }
    }
    return max;
}
  • Lines 22–29: The [sum] method accepts a list of elements of any type derived from [Number]: [Integer], [Double], [BigDecimal]… It treats the elements as [Number] and uses their [doubleValue] method. Java does not support generic arithmetic: you cannot write [a + b] for two objects of type [T], hence the need to use [double]. Adding an element to the list (line 27) is rejected: the compiler does not know whether it is a list of [Integer] or [Double];
  • lines 6–7: the same method concatenates a list of [Integer] with a list of [Double]. [List.of(...)] creates a read-only list;
  • lines 32–36: the [addIntegers] method accepts any list capable of holding [Integer] entries, in this case a [List<Number>] (line 10). We follow the rule “producer: [extends], consumer: [super]”: a list whose elements are read is written using [extends], and a list that is filled is written using [super];
  • Lines 39–47: The generic method *[max] returns the largest element in a list. The [T extends Comparable<? super T>] bound requires that the elements be comparable to one another; it applies to strings as well as integers (line 14). The [getFirst]* method (Java 21) returns the first element of the list;
  • lines 16–18: At runtime, a ***[List<String>] and a ***[List<Integer>] are two objects of the same class, *[ArrayList]*. The compiler checks the generic types and then erases them: the compiled code only recognizes [ArrayList] and [Object], and conversions are inserted where necessary. This “type erasure,” adopted in 2004 to ensure that generic code remains compatible with existing code, explains several limitations: you cannot write [new T()] or [new T[10]], nor can you test [o instanceof List<String>], nor can you use a primitive type as a generic parameter.

Results:

1
2
3
4
5
6
> java Classes21.java
Sum of integers: 6.0
Sum of reals: 3.75
numbers=[1, 2, 3, 2.5], sum=8.5
max : valentine, 3
same class: true (java.util.ArrayList)

Unlike other languages such as C#, where interfaces can declare abstract static members to implement generic computations, Java does not allow requiring a type like [T] to have a static method: static methods of an interface are called using the interface’s name and are not inherited by the classes that implement it.

4.9. Packages

To write a line on the screen, we use the [IO.println(...)] statement. If we look at the definition of the [IO] class in the JDK documentation, we find that it is part of the [java.lang] package. This means that its full name is [java.lang.IO] and that we could write [java.lang.IO.println(...)]. The classes in the [java.lang] package ([String], [Object], [Math], [Integer]…) are always referred to by their simple names. For the others, a [import] clause is used, placed at the beginning of the file:

  • [import java.util.List;] imports the class [List] from the package [java.util];
  • [import java.util.*;] imports all public classes from the [java.util] package on demand;
  • [import module java.base;] (Java 25) imports all public classes from the packages exported by the [java.base] module (see below). This is what a compact file does implicitly;
  • [import static java.lang.Math.sqrt;] imports a static member of a class, which can then be used without specifying the class name: [sqrt(2)].

When the compiler encounters a class’s short name, it looks for it in the current package and among the imported classes. A [import] clause adds nothing to the program: it merely avoids having to write out the full names.

When you create a class, you place it in a package. The purpose of packages is to avoid name conflicts between classes, for example, when they are distributed. Consider two companies, E1 and E2, distributing classes packaged in the archives [e1.jar] and [e2.jar], respectively. Suppose a customer C purchases these two sets of classes, in which both companies have defined a class named [Person]. If company E1 creates its classes in a package named [e1] and company E2 creates its classes in a package named [e2], the two classes [Person] will then be named [e1.Person] and [e2.Person]. The client can import one of them and refer to the other by its full name. The package resolves this ambiguity. By convention, package names are in lowercase and begin with the reverse domain name of the organization that publishes them ([com.mysql.cj.jdbc], [org.hibernate]…); for our examples, we will use short names.

To place a class in a package, you write the declaration [package name;] at the top of its file. Unlike other languages, Java requires a one-to-one correspondence between packages and directories: the source files for the [e1] package are stored in a folder named [e1], and those for the [tax.dao] package are stored in a folder named [tax/dao], starting from the root of the source directory. The [Namespaces] folder illustrates these concepts:

1
2
3
4
5
6
Namespaces/
  Main.java             the main program (unnamed package)
  e1/
    Person.java         the class e1.Person
  e2/
    Person.java         the class e2.Person

The first class, [Person] [e1/Person.java]:

// the classes of this file belong to the e1 package
package e1;

public class Person {
    private final String firstName;
    private final String lastName;

    public Person(String firstName, String lastName) {
        this.firstName = firstName;
        this.lastName = lastName;
    }

    @Override
    public String toString() {
        return "e1.Person[" + firstName + ", " + lastName + "]";
    }
}

The second: [e2/Person.java]:

// the classes of this file belong to the e2 package
package e2;

public class Person {
    private final String name;

    public Person(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

    @Override
    public String toString() {
        return "e2.Person[" + name + "]";
    }
}

Both classes are public: a class without the keyword [public] is accessible only to classes in its own package. The main program [Main.java] is as follows:

// importing a class from a package
import e1.Person;
// static import of a static member of a class
import static java.lang.Math.sqrt;

void main() {
    // Person designates e1.Person, which was imported
    Person p1 = new Person("Jean", "Dupont");
    // the full name of a class removes any ambiguity
    e2.Person p2 = new e2.Person("Durand");
    IO.println(p1 + " " + p2);
    // full name and package of a class
    IO.println(Person.class.getName() + ", " + e2.Person.class.getName());
    IO.println("package of the Person class: " + Person.class.getPackageName());
    // the module that contains a class
    IO.println("e1.Person is in a named module: " + Person.class.getModule().isNamed());
    IO.println(String.class.getName() + " is in the module " + String.class.getModule().getName());
    // StringBuilder is known thanks to the automatic import of the java.base module
    StringBuilder sb = new StringBuilder("abc");
    IO.println(sb.append("def"));
    // sqrt is known thanks to the static import
    IO.println("sqrt(2)=" + sqrt(2));
}
  • line 2: import of the class [e1.Person];
  • line 4: static import of the method [sqrt] from the class [Math];
  • Line 8: [Person] refers to the class [e1.Person], which was imported;
  • Line 10: The full name of the class [e2.Person] removes any ambiguity. Java does not support type aliases: you cannot give a class a different name;
  • Lines 13–14: The method [getName] in the class description returns its full name, prefixed by its package; [getPackageName] returns the package name;
  • lines 16–17: the method [getModule] returns the module containing the class (see below);
  • lines 19–20: the class [java.lang.StringBuilder] is recognized without an import;
  • line 22: use of static import.

The source code launcher automatically finds classes in subfolders: the command [java Main.java], entered in the [Namespaces] folder, compiles the files [e1/Person.java] and [e2/Person.java] on demand. Results:

1
2
3
4
5
6
7
8
> java Main.java
e1.Person[Jean, Dupont] e2.Person[Durand]
e1.Person, e2.Person
package of the Person class: e1
e1.Person is in a named module: false
java.lang.String is in the module java.base
abcdef
sqrt(2)=1.4142135623730951

If we replace the import on line 2 with the two on-demand imports [import e1.*;] and [import e2.*;], the name [Person] becomes ambiguous and the compiler rejects it:

1
2
3
4
5
6
> java Main.java
Main.java:9: error: reference to Person is ambiguous
    Person p1 = new Person("Jean", "Dupont");
    ^
  both class e2.Person in e2 and class e1.Person in e1 match
...

4.9.1. Modules

Starting with Java 9, packages are themselves grouped into modules (“Java Platform Module System,” JPMS). JDK is divided into modules: [java.base] contains the core packages ([java.lang], [java.util], [java.math], [java.io]…), [java.sql] provides database access, [java.net.http] is the client, HTTP, etc. A module declares, in a file named [module-info.java] located at the root of its source code, its name, the modules it requires ([requires java.sql;]), and the packages it makes available to other modules ([exports tax.business;]): An unexported package is invisible from the outside, even for its public classes. The classes in an application that does not declare a module—such as ours—belong to the unnamed module (line 5 of the results), which has access to all modules. This is the case for most applications, and for all those in this course: modules are primarily of interest to library authors and to the JDK module itself.

4.10. Records

Many classes serve only to carry data: a person, a grade, a tax bracket… For these, we always write the same code: private fields and [final], a constructor that copies its parameters into the fields, accessors, a [toString] method that displays the fields, and the [equals] and [hashCode] methods. Java 16 introduced records: the compiler generates this code from a single-line declaration. We encountered them in the section on immutable objects; the program [Classes22.java] demonstrates them more fully:

// records (Java 16)
void main() {
    Person r1 = new Person("Jean", "Dupont", 30);
    Person r2 = new Person("Jean", "Dupont", 30);
    // toString generated by the compiler
    IO.println(r1);
    // the accessors have the name of the components
    IO.println("first name=" + r1.firstName() + ", identity=" + r1.identity());
    // equality by value: r1 and r2 are two different but equal objects
    IO.println("r1.equals(r2) : " + r1.equals(r2) + ", r1 == r2 : " + (r1 == r2));
    // the components are read-only
    // r1.age = 31;   // rejected: the age field is final
    // a modified copy, with a "wither" method
    Person r3 = r1.withAge(31);
    IO.println(r3);
    IO.println("r1.equals(r3) : " + r1.equals(r3));
    // deconstruction into variables with a record pattern (Java 21)
    if (r1 instanceof Person(String firstName, String lastName, int age)) {
        IO.println("first name=" + firstName + ", last name=" + lastName + ", age=" + age);
    }

    // the same thing with a class
    PersonC c1 = new PersonC("Jean", "Dupont", 30);
    PersonC c2 = new PersonC("Jean", "Dupont", 30);
    IO.println(c1);
    IO.println("c1.equals(c2) : " + c1.equals(c2));

    // two records that implement the same interface
    Teacher e = new Teacher("Lucile", "Dumas", 56, 61);
    IO.println(e);
    IO.println(e.identity());
    // equality takes the type into account: a Person is not equal to a Teacher
    Person pe = new Person("Lucile", "Dumas", 56);
    IO.println("pe.equals(e) : " + pe.equals(e));
    // the common type: the HasIdentity interface
    HasIdentity[] identities = { pe, e };
    for (HasIdentity i : identities) {
        IO.println(i.identity());
    }
}

// an interface implemented by the records
interface HasIdentity {
    String identity();
}

// a record: firstName, lastName, age are its components
record Person(String firstName, String lastName, int age) implements HasIdentity {
    // methods can be added
    @Override
    public String identity() {
        return "[" + firstName + ", " + lastName + ", " + age + "]";
    }

    // a "wither" method: a copy in which one component has changed
    public Person withAge(int age) {
        return new Person(firstName, lastName, age);
    }
}

// a record cannot derive from another record
record Teacher(String firstName, String lastName, int age, int section) implements HasIdentity {
    @Override
    public String identity() {
        return "Teacher[[" + firstName + ", " + lastName + ", " + age + "]," + section + "]";
    }
}

// an equivalent class... or almost
final class PersonC {
    private final String firstName;
    private final String lastName;
    private final int age;

    public PersonC(String firstName, String lastName, int age) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.age = age;
    }

    public String firstName() {
        return firstName;
    }

    public String lastName() {
        return lastName;
    }

    public int age() {
        return age;
    }
}
  • lines 48–59: the record [Person]. The declaration [record Person(String firstName, String lastName, int age)] generates:
    • three private fields—[final], [firstName], [lastName], and [age]—which are the components of the record;
    • a three-parameter constructor, known as the canonical constructor;
    • three public accessors: [firstName()], [lastName()], and [age()] (line 8);
    • a method [toString] that displays the type name and its components (line 6);
    • the methods [equals] and [hashCode], which compare the values of the components rather than the references;
  • lines 51–53 and 56–58: a record, like a class, can have additional methods and implement interfaces (line 48);
  • line 10: [r1] and [r2] are two distinct objects ([==] is the same as [false]) but they are equal to [equals] because their components have the same values. For an ordinary class, [equals] compares references (line 26);
  • line 14: since a record is immutable, it cannot be modified (line 12): the “wither” method [withAge] creates a copy of [r1] whose age has changed;
  • lines 18–20: a record pattern (Java 21): if [r1] is a [Person], its components are extracted into the variables [firstName], [lastName], and [age]. This is referred to as record decomposition. We will encounter these patterns again when filtering by pattern;
  • lines 62–67: a record is implicitly final and implicitly derives from the class [java.lang.Record]; it cannot derive from another class or record, nor can it be derived from. To give the records [Person] and [Teacher] a common type, we have them implement the same interface, in this case [HasIdentity] (lines 43–45). The record [Teacher] therefore contains the person’s components;
  • line 34: record equality takes into account the actual type of the objects: a [Person] is never equal to a [Teacher], even if their common components are equal;
  • lines 36–39: the common interface allows both records to be handled polymorphically;
  • lines 70–92: a class that resembles the [Person] record… except that the fields, constructor, and accessors had to be written, and equality by value and [toString] are not provided.

Results:

> java Classes22.java
Person[firstName=Jean, lastName=Dupont, age=30]
first name=Jean, identity=[Jean, Dupont, 30]
r1.equals(r2) : true, r1 == r2 : false
Person[firstName=Jean, lastName=Dupont, age=31]
r1.equals(r3) : false
first name=Jean, last name=Dupont, age=30
Classes22$PersonC@206a70ef
c1.equals(c2) : false
Teacher[firstName=Lucile, lastName=Dumas, age=56, section=61]
Teacher[[Lucile, Dumas, 56],61]
pe.equals(e) : false
[Lucile, Dumas, 56]
Teacher[[Lucile, Dumas, 56],61]

Note that the method [toString] of the class [PersonC] is the same as that of the class [Object] (line 8 of the results) and that the equality of the two objects [PersonC] is false (line 9).

Let’s summarize the differences between a class and a record:

 
Class
Record
Equality between [equals]
same object (unless redefined)
same component values, same type
[toString]
class name and hash code
record name and components
fields, constructor, accessors
to be written
generated by the declaration
status
editable or not
immutable (fields [final]), no other instance fields
modified copy
to be written
to be written (using "wither" methods)
inheritance
from a class, from multiple interfaces
from no classes, from multiple interfaces; a record is always final

A record is used for data defined by its value (a person retrieved from a database, a message exchanged over a network, a tax bracket), and a class is used for an object with an identity and behavior (a computing service, a connection, a mutable list). Note: A record is “superficially” immutable: if a component references a mutable object—such as a list—the contents of that list remain mutable. In such cases, immutable lists are used ([List.copyOf]).

4.11. Record constructors

The canonical constructor for a record simply copies its parameters into the fields. However, you may want to validate or normalize these parameters. Java provides the compact constructor for this purpose, as well as the ability to add other constructors. The program [Classes23.java] illustrates this:

// record constructors: compact constructor, other constructors
void main() {
    Person p = new Person("  Jean ", "Dupont", 30);
    IO.println(p.identity());
    // a record is immutable: growOlder returns a new person
    Person p2 = p.growOlder();
    IO.println(p.identity() + " -> " + p2.identity());
    // another constructor
    Person unknown = new Person("Martin");
    IO.println(unknown.identity());
    // the checks of the compact constructor
    try {
        new Person("Paul", " ", 20);
    } catch (IllegalArgumentException ex) {
        IO.println(ex.getMessage());
    }
    try {
        new Person("Paul", "Martin", -4);
    } catch (IllegalArgumentException ex) {
        IO.println(ex.getMessage());
    }
    // a record with a method and a static factory
    Point pt = new Point(3, 4);
    IO.println("pt=" + pt + ", distance to the origin: " + pt.distance() + ", origin=" + Point.origin());
}

record Person(String firstName, String lastName, int age) {
    // compact constructor: it checks and normalizes the parameters,
    // the fields are then initialized automatically
    Person {
        firstName = check(firstName, "first name");
        lastName = check(lastName, "last name");
        if (age < 0) {
            throw new IllegalArgumentException("age (" + age + ") invalid");
        }
    }

    // any other constructor must call the canonical constructor
    Person(String lastName) {
        this("?", lastName, 0);
    }

    // a method that returns a new person
    public Person growOlder() {
        return new Person(firstName, lastName, age + 1);
    }

    public String identity() {
        return "[" + firstName + ", " + lastName + ", " + age + "]";
    }

    // a static checking method
    private static String check(String value, String label) {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException(label + " (" + value + ") invalid");
        }
        return value.strip();
    }
}

record Point(double x, double y) {
    // a static field is allowed, not an instance field
    private static final Point ORIGIN = new Point(0, 0);

    public static Point origin() {
        return ORIGIN;
    }

    public double distance() {
        return Math.sqrt(x * x + y * y);
    }
}
  • Lines 30–36: the compact constructor. It is written without a parameter list; its parameters are implicitly those of the canonical constructor ([firstName], [lastName], [age]). Its code executes first; then the fields are automatically initialized with the values—which may have been modified—of the parameters. Here, the first name and last name are validated and stripped of their leading and trailing spaces (lines 31–32), and a negative age is rejected (lines 33–35). In a compact constructor, you cannot assign values to fields ([this.firstName = ...] is rejected): you modify the parameters;
  • lines 39–41: a record may have other constructors, but these must call the canonical constructor with [this(...)]: any creation of [Person] thus goes through the compact constructor’s validations;
  • lines 44–46: a record is immutable; aging it means creating a new instance;
  • lines 53–58: a static private validation method;
  • lines 61–72: A record can have static fields (line 63) and static methods (lines 65–67), such as the static factory [origin], but no instance fields in addition to its components.

Results:

1
2
3
4
5
6
7
> java Classes23.java
[Jean, Dupont, 30]
[Jean, Dupont, 30] -> [Jean, Dupont, 31]
[?, Martin, 0]
last name ( ) invalid
age (-4) invalid
pt=Point[x=3.0, y=4.0], distance to the origin: 5.0, origin=Point[x=0.0, y=0.0]

The first name, provided surrounded by spaces (line 3), was stored without them (line 2 of the results). The compact constructor is the idiomatic way to ensure that a record is always valid: there is no way to create a [Person] without using it.

4.12. No extension methods: static utility methods

How do you add a method to a class that cannot be modified, such as the [String] class derived from JDK? Some languages, such as C# or Kotlin, offer “extension methods”: static methods that are used like instance methods of the extended type. Java does not have them. Instead, we write static methods, grouped together in a utility class, and call them using the name of their class, passing the object as a parameter. The JDK program is full of them: [Arrays], [Collections], [Objects], [Math]… The program [Classes24.java] illustrates this:

// no extension methods in Java: static utility methods
import static java.lang.Character.toUpperCase;

void main() {
    Person p = new Person("jean", "dupont", 30);
    // the utility methods are called with the name of their class
    IO.println(Tools.initials(p));
    IO.println(Tools.firstUpper("jean"));
    IO.println(Tools.fullName(p) + " is an adult: " + Tools.isAdult(p));
    IO.println("\"it is sunny\" has " + Tools.nbWords("it is sunny") + " words");
    // a static method of the record itself
    Person unknown = Person.unknown();
    IO.println(Tools.fullName(unknown) + " is an adult: " + Tools.isAdult(unknown));
}

// a person
record Person(String firstName, String lastName, int age) {
    // static factory: an unknown person
    static Person unknown() {
        return new Person("?", "?", 0);
    }
}

// a utility class: final, private constructor, static methods
final class Tools {
    private Tools() {
    }

    static String initials(Person p) {
        return toUpperCase(p.firstName().charAt(0)) + "." + toUpperCase(p.lastName().charAt(0)) + ".";
    }

    static String firstUpper(String s) {
        return s.isEmpty() ? s : toUpperCase(s.charAt(0)) + s.substring(1);
    }

    static boolean isAdult(Person p) {
        return p.age() >= 18;
    }

    static String fullName(Person p) {
        return firstUpper(p.firstName()) + " " + p.lastName().toUpperCase();
    }

    static int nbWords(String s) {
        return s.isBlank() ? 0 : s.strip().split("\\s+").length;
    }
}
  • lines 25–48: the utility class [Tools]. It is final, and its sole constructor is private (lines 26–27): it cannot be derived from or instantiated. All of its methods are static;
  • lines 29–31: the method [initials] takes the person as a parameter. Like a utility class written in another file, it uses only the public members of the [Person] record—in this case, its accessors (in a compact file, however, it would have access to private fields); [charAt(0)] returns the first character of a string;
  • line 2: The static import of the method [Character.toUpperCase], which capitalizes a character, allows it to be called without its class name (lines 30 and 34);
  • lines 45–47: the method [nbWords] splits the string using the method [split], whose parameter is a regular expression: ["\\s+"] matches a sequence of one or more whitespace characters;
  • lines 7–10: Utility methods are called using the class name: [Tools.initials(p)], not [p.initials()];
  • lines 19–21: when the type can be modified, its methods are naturally placed there: the static factory [unknown] is a method of the record [Person] (line 12).

Results:

1
2
3
4
5
6
> java Classes24.java
J.D.
Jean
Jean DUPONT is an adult: true
"it is sunny" has 3 words
? ? is an adult: false

With a static import ([import static tools.Tools.*;] if the class were in a package named [tools]), one could write [initials(p)] without the class name. For a utility method to be usable, its class must be accessible and, if it is in another package, imported.

4.13. Pattern matching on objects

The [switch] statement and pattern matching are not limited to simple values (numbers, strings); they also apply to objects. You can test an object’s type, deconstruct a record, add a condition, and retrieve the object converted to the correct type—all in a single expression. The program [Classes25.java] uses a sealed interface and three records that implement it:

// pattern matching on objects
void main() {
    Object[] objects = {
        new Individual("Jean", "Dupont", 30),
        new Individual("Lise", "Morel", 12),
        new Teacher("Lucile", "Dumas", 56, 61),
        new Teacher("Marc", "Petit", 44, 27),
        new Student("Paul", "Martin", 19, "computer science"),
        "a string",
        42,
        null,
    };

    // 1 - the type pattern: instanceof Type variable (Java 16)
    for (Object o : objects) {
        if (o instanceof Person p) {
            // here p is a Person
            IO.println(p.firstName() + " is of type " + p.getClass().getSimpleName());
        }
    }

    // 2 - the type pattern followed by a condition
    for (Object o : objects) {
        if (o instanceof Person p && p.age() >= 18) {
            IO.println(p.firstName() + " is an adult");
        }
    }

    // 3 - the switch on the type of the object, with guards and record patterns (Java 21)
    for (Object o : objects) {
        IO.println(describe(o));
    }

    // 4 - an exhaustive switch on a sealed interface
    for (Object o : objects) {
        if (o instanceof Person p) {
            IO.println(p.firstName() + " : " + category(p));
        }
    }
}

String describe(Object o) {
    return switch (o) {
        case Teacher e when e.section() == 27 -> e.firstName() + " teaches computer science";
        case Teacher e -> e.firstName() + " teaches in section " + e.section();
        case Student(var firstName, _, _, var major) -> firstName + " studies " + major;
        case Person p when p.age() < 18 -> p.firstName() + " is a minor";
        case Person p -> p.firstName() + " is " + p.age() + " years old";
        case String s -> "a string of " + s.length() + " characters";
        case Integer n when n > 0 -> "the positive integer " + n;
        case null -> "the null reference";
        default -> "an unknown object";
    };
}

// no default: the compiler knows that the three cases cover all persons
String category(Person p) {
    return switch (p) {
        case Individual _ -> "individual";
        case Teacher _ -> "teacher";
        case Student _ -> "student";
    };
}

// a sealed interface and the three records that implement it
sealed interface Person permits Individual, Teacher, Student {
    String firstName();
    String lastName();
    int age();
}

record Individual(String firstName, String lastName, int age) implements Person {
}

record Teacher(String firstName, String lastName, int age, int section) implements Person {
}

record Student(String firstName, String lastName, int age, String major) implements Person {
}
  • Lines 66–79: A sealed interface [Person], implemented by three records: [Individual], [Teacher], and [Student]. The interface declares the methods [firstName], [lastName], and [age]: the records automatically implement them through their accessors, since they have components with the same names. Since a record is final, it can implement a sealed interface without further specification;
  • lines 3–12: an array of objects of various types, including an integer (automatically converted to *[Integer]) and the reference *[null];
  • line 16: the pattern of type [o instanceof Person p] (Java 16) is true if [o] references an object of type [Person]; in this case, the variable [p], of type [Person], references this object. This avoids the sequence used in Java prior to version 16: testing the type with [instanceof], then converting with [(Person) o]. The pattern is false if [o] is equal to [null];
  • line 24: the pattern variable [p] can be used in the rest of the condition, after [&&]: Java does not have property patterns; field values are tested using a condition;
  • lines 42–54: the expression [switch] on an object (Java 21) tries the patterns in order and returns the value associated with the first one that matches:
    • line 44: a teacher from section 27. The clause [when] adds a condition, called a guard, to the type pattern;
    • line 45: any other teacher. The order is important: if placed before the previous one, this pattern would have made it inaccessible, which the compiler would have reported as an error;
    • line 46: a record pattern: it extracts the components of the record [Student] in the order they are declared; [var] lets the compiler infer their type, and the character [_] (Java 22) ignores a component;
    • lines 47–48: a minor, followed by any other person;
    • line 50: a positive integer;
    • line 51: the case [null]. Without it, a [switch] on a null reference throws a [NullPointerException] exception;
    • line 52: the case [default] handles everything else. Without it, the compiler would report that the expression [switch] does not cover all cases: a [switch] on an object must be exhaustive;
  • lines 57–63: a [switch] on a [Person], without [default]. However, it is exhaustive: since the interface is sealed, the compiler knows that a person is either a [Individual], a [Teacher], or a [Student]. The pattern [Individual _] tests the type without declaring a variable.

Results:

> java Classes25.java
Jean is of type Individual
Lise is of type Individual
Lucile is of type Teacher
Marc is of type Teacher
Paul is of type Student
Jean is an adult
Lucile is an adult
Marc is an adult
Paul is an adult
Jean is 30 years old
Lise is a minor
Lucile teaches in section 61
Marc teaches computer science
Paul studies computer science
a string of 8 characters
the positive integer 42
the null reference
Jean : individual
Lise : individual
Lucile : teacher
Marc : teacher
Paul : student

If the case [Student] is removed from the method [category], the compiler reports an error:

1
2
3
4
5
6
> java Classes25.java
Classes25.java:58: error: the switch expression does not cover all possible input values
    return switch (p) {
           ^
1 error
error: compilation failed

This is the great advantage of combining sealed types with pattern filtering: if a fourth record is added to the hierarchy, the compiler identifies all the [switch] records that need to be filled in. Note also that the type pattern corresponds to the types that implement the interface: the type pattern loop (lines 15–20) has selected the three types of people. Pattern filtering is a useful alternative to cascading [if (o instanceof ...)] statements; however, it should not replace polymorphism: when behavior depends on the object’s type and the class hierarchy is well-defined, a method redefined in each class often remains the object-oriented solution. Pattern-based filtering is well-suited for data hierarchies (records) on which one wishes to write processing logic external to that data.

4.14. Handling the null value

Any reference-type variable can have the value [null], and using a null reference causes the [NullPointerException] exception, the most common runtime error in Java. Unlike other languages such as C# or Kotlin, the Java compiler does not distinguish between types that can be [null] and those that cannot. However, the JDK and libraries provide several tools to mitigate this risk:

  • the [Optional<T>] class (Java 8), the return type of a method that may return nothing;
  • the methods of the [Objects] class: [requireNonNull] to reject [null] at the entry point of a method or constructor, and [requireNonNullElse] to provide a default value;
  • annotations that document intent and are verified by certain analysis tools. The most recent ones, proposed by a group of vendors (Google, JetBrains, Spring…), are those from the JSpecify library: [@Nullable] on a type that can be [null], and [@NullMarked] on a class or package to indicate that other types cannot be. Spring Framework 7, which we will be using, employs them. They require a library, so we will not use them in this chapter.

The program [Classes26.java] demonstrates the first two:

// handling the null value: Optional, Objects.requireNonNull...
void main() {
    Person jean = new Person("Jean", "Dupont");
    jean.setMiddleName("Marie");
    Person[] persons = { jean, new Person("Paul", "Martin") };
    for (Person p : persons) {
        // a default value when the reference is null
        String middle = Objects.requireNonNullElse(p.getMiddleName(), "(none)");
        // an explicit test
        String length = p.getMiddleName() == null ? "null" : String.valueOf(p.getMiddleName().length());
        IO.println(p.getFirstName() + " : middle name=" + middle + ", length=" + length);
    }

    // a method that may find nothing: it returns an Optional<Person>
    Optional<Person> found = find(persons, "Durand");
    IO.println("Durand found: " + found.isPresent());
    IO.println(found.map(Person::getFirstName).orElse("Durand: person not found"));
    // we test the presence of a value before using it
    found = find(persons, "Martin");
    if (found.isPresent()) {
        IO.println("Martin : " + found.get().getFirstName());
    }
    // ifPresent: the action is only performed if a value is present
    find(persons, "Dupont").ifPresent(p -> p.setMiddleName("Louise"));
    find(persons, "Durand").ifPresent(p -> p.setMiddleName("Louise"));
    IO.println(jean.getMiddleName());
    // orElseThrow: we assert that the value is present, otherwise an exception is thrown
    Person martin = find(persons, "Martin").orElseThrow();
    IO.println(martin.getFirstName());

    // Objects.requireNonNull: a mandatory parameter
    try {
        new Person(null, "Morel");
    } catch (NullPointerException ex) {
        IO.println("Error: " + ex.getMessage());
    }
    // the NullPointerException exception and its detailed message (Java 14)
    try {
        Person lise = new Person("Lise", "Morel");
        IO.println(lise.getMiddleName().length());
    } catch (NullPointerException ex) {
        IO.println("Error: " + ex.getMessage());
    }
}

// searching for a person by last name
Optional<Person> find(Person[] persons, String lastName) {
    for (Person p : persons) {
        if (p.getLastName().equals(lastName)) {
            return Optional.of(p);
        }
    }
    return Optional.empty();
}

class Person {
    // mandatory fields: they must never be null
    private final String firstName;
    private final String lastName;
    // optional field: null means "no middle name"
    private String middleName;

    public Person(String firstName, String lastName) {
        this.firstName = Objects.requireNonNull(firstName, "the first name is mandatory");
        this.lastName = Objects.requireNonNull(lastName, "the last name is mandatory");
    }

    public String getFirstName() {
        return firstName;
    }

    public String getLastName() {
        return lastName;
    }

    public String getMiddleName() {
        return middleName;
    }

    public void setMiddleName(String middleName) {
        this.middleName = middleName;
    }
}
  • lines 56–83: The [Person] class has two required fields, initialized by the constructor, and one optional field, [middleName]: [null] means “no middle name.” The constructor rejects a first name or last name of [null] using [Objects.requireNonNull] (lines 64–65): the error is detected when the object is created rather than much later, when the field is used;
  • line 8: [Objects.requireNonNullElse(value, defaultValue)] (Java 9) returns [value] if it is not equal to [null]; otherwise, it returns [defaultValue];
  • line 10: An explicit check is often the simplest approach;
  • lines 47–54: The method [find] returns the person being searched for wrapped in a [Optional] object, or an empty [Optional]: its return type, [Optional<Person>], warns the caller that the result may be missing, and requires the caller to handle this case to retrieve the person. [Optional.of(p)] creates a [Optional] containing [p], [Optional.empty()], and an empty [Optional];
  • line 16: [isPresent] indicates whether [Optional] contains a value;
  • line 17: [map(Person::getFirstName)] converts any person present into their first name; [orElse(value)] returns the present value or, if none, the specified value. The notation [Person::getFirstName] is a method reference, which we will examine in Chapter 7; here, it refers to “the person’s [getFirstName] method”;
  • lines 20–22: [get] returns the present value; it is called only after verifying its presence;
  • lines 24–25: [ifPresent] executes the specified action only if a value is present. The action is a lambda (Chapter 7): [p -> p.setMiddleName("Louise")] reads “for person p, set their middle name.” Dupont exists and is assigned a new middle name; Durand does not exist, and the statement does nothing;
  • Line 28: [orElseThrow] returns the value if it is present, or throws a [NoSuchElementException] exception if [Optional] is empty. This is used when you are certain the value is present;
  • lines 32–36: A person without a first name is rejected by the constructor;
  • lines 38–43: Using a null reference throws the [NullPointerException] exception. Since Java 14, its message specifically identifies the null expression.

Results:

> java Classes26.java
Jean : middle name=Marie, length=5
Paul : middle name=(none), length=null
Durand found: false
Durand: person not found
Martin : Paul
Louise
Paul
Error: the first name is mandatory
Error: Cannot invoke "String.length()" because the return value of "Classes26$Person.getMiddleName()" is null

[Optional] is intended for method return values; it is not used for fields or parameters. In this course, we document whether a value is optional, verify required parameters using [Objects.requireNonNull], and prefer objects that are always complete (constructed with all their required values) over objects that are populated incrementally.

4.15. Example Application - V2

We’ll revisit the tax calculation already covered in the previous chapter (version 1) and now implement it using classes and interfaces. Let’s review the problem. We aim to write a program to calculate a taxpayer’s tax. We’ll consider the simplified case of a taxpayer who has only their salary to report (the 2004 tax schedule for 2003 income, used as-is: this is an exercise):

  • we calculate the employee’s number of shares: shares = nbChildren / 2 + 1 if they are unmarried, nbChildren / 2 + 2 if he is married, where nbChildren is the number of his children;
  • if he has at least three children, he receives an additional half-share;
  • his taxable income R is calculated as R = 0.72 × S, where S is his annual salary;
  • his family quotient is calculated as QF = R / shares;
  • his tax I is calculated using the following table:
threshold
coeffR
coeffN
4962
0
0
8382
0.068
291.09
14,753
0.191
1,322.92
23,888
0.283
2,668.39
38,868
0.374
4,846.98
47,932
0.426
6,883.66
0
0.481
9,505.54

Each row has three fields. To calculate tax I, find the first row where QF ≤ the limit. For example, if QF = 5,000, the row (8,382; 0.068; 291.09) will be found. Tax I is then equal to 0.068 × R - 291.09 × shares. If QF is such that the condition QF ≤ limit is never satisfied, then the coefficients of the last row are used, resulting in tax I = 0.481 × R - 9,505.54 × shares.

The application generates the file [TaxV2]. The calculation classes are organized into a package named [tax]:

1
2
3
4
5
6
7
TaxV2/
  Main.java                 the program that dialogues with the user
  tax/
    TaxBracket.java         a bracket of the tax scale
    ITax.java               the tax calculation interface
    AbstractTax.java        an abstract class implementing ITax
    HardwiredTax.java       a derived class, with hard-coded data

First, we define a type capable of encapsulating a row from the previous array [TaxBracket.java]:

1
2
3
4
5
6
7
package tax;

import java.math.BigDecimal;

// a bracket of the tax scale
public record TaxBracket(BigDecimal limit, BigDecimal coeffR, BigDecimal coeffN) {
}

We use a record:

  • because a bracket is a small piece of data without an identity, defined solely by its value, and there is no reason for it to be modified once created: a record is immutable;
  • because the compiler generates the constructor, the accessors, [toString], and the equality operator from a single line.

The components are of type [BigDecimal] (package [java.math], hence the import on line 3). The types [float] and [double] represent numbers in binary: a decimal number such as 0.068 has no exact representation in them, and calculations accumulate small rounding errors. The [BigDecimal] class represents decimal numbers exactly: this is the type to use for amounts. Its drawback is the lack of operators: we write [a.add(b)], [a.multiply(b)], [a.compareTo(b)].

Next, we define an interface [ITax] capable of calculating the tax [ITax.java]:

1
2
3
4
5
6
package tax;

public interface ITax {
    // tax calculation of a taxpayer
    int calculate(boolean married, int nbChildren, int salary);
}
  • Line 5: The method for calculating the tax based on three pieces of data: the taxpayer’s marital status, the number of children, and annual salary. It rounds the tax down to the nearest euro.

Next, we define an abstract class that implements this interface [AbstractTax.java]:

package tax;

import java.math.BigDecimal;
import java.math.MathContext;
import java.util.List;

public abstract class AbstractTax implements ITax {
    // the tax brackets needed for the calculation come from an external source
    // that only the derived classes know
    protected abstract List<TaxBracket> getTaxBrackets();

    // tax calculation
    @Override
    public int calculate(boolean married, int nbChildren, int salary) {
        // calculation of the number of shares
        BigDecimal shares = BigDecimal.valueOf(nbChildren).divide(BigDecimal.TWO)
                .add(BigDecimal.valueOf(married ? 2 : 1));
        if (nbChildren >= 3) {
            shares = shares.add(new BigDecimal("0.5"));
        }
        // calculation of the taxable income and of the family quotient
        BigDecimal income = new BigDecimal("0.72").multiply(BigDecimal.valueOf(salary));
        BigDecimal quotient = income.divide(shares, MathContext.DECIMAL128);
        // searching for the tax bracket: the last one is used if no limit fits
        List<TaxBracket> brackets = getTaxBrackets();
        TaxBracket bracket = brackets.getLast();
        for (int i = 0; i < brackets.size() - 1; i++) {
            if (quotient.compareTo(brackets.get(i).limit()) <= 0) {
                bracket = brackets.get(i);
                break;
            }
        }
        // tax calculation
        return income.multiply(bracket.coeffR()).subtract(shares.multiply(bracket.coeffN())).intValue();
    }
}
  • Line 1: The class belongs to the [tax] package; its file is therefore in the [tax] folder;
  • Line 7: The class [AbstractTax] implements the interface [ITax];
  • Line 10: The scale data, provided by a protected abstract method. The [AbstractTax] class does not know where this data comes from (written in the code, read from a file, a database, etc.). It leaves this up to the derived classes. This is why it is declared abstract, which prevents it from being instantiated. The abstract method requires derived classes to provide the brackets; the compiler verifies this;
  • lines 13–35: the implementation of the [calculate] method of the [ITax] interface. Derived classes will not need to rewrite this method. The [AbstractTax] class thus serves as a factorization class for the derived classes: it contains what is common to all of them;
  • lines 16–17: the number of shares. [BigDecimal.valueOf(n)] converts an integer to [BigDecimal]; division by [BigDecimal.TWO] is exact (3 / 2 = 1.5), unlike integer division by [nbChildren / 2] (3 / 2 = 1). The expression [married ? 2 : 1] equals 2 for a married taxpayer, 1 otherwise;
  • lines 18–20: the additional half-share. A [BigDecimal] is immutable: [add] returns a new object, which must be assigned to the variable;
  • lines 22–23: taxable income and the family quotient. Decimal constants are created from strings ([new BigDecimal("0.72")]) to ensure they are exact. Dividing a [BigDecimal] can result in an infinite number of decimal places (1 / 3): we specify the desired precision, in this case [MathContext.DECIMAL128] (34 significant digits);
  • lines 25–32: searching for the bracket. By default, this is the last bracket ([brackets.getLast()], line 26). The loop iterates through all brackets except the last one and stops ([break]) at the first bracket whose limit is greater than or equal to the family quotient, as tested by [compareTo(...) <= 0];
  • line 34: the tax calculation. The method [intValue] truncates the result to its integer part.

A class that implements the [ITax] interface can be created by deriving it from the [AbstractTax] class. This is what we are now doing with [HardwiredTax.java]:

package tax;

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;

public class HardwiredTax extends AbstractTax {
    // tax calculation data (2004 tax scale on 2003 income), "hardwired"
    private static final double[] LIMITS = { 4962, 8382, 14753, 23888, 38868, 47932, 0 };
    private static final double[] COEFF_R = { 0, 0.068, 0.191, 0.283, 0.374, 0.426, 0.481 };
    private static final double[] COEFF_N = { 0, 291.09, 1322.92, 2668.39, 4846.98, 6883.66, 9505.54 };

    // the tax brackets
    private final List<TaxBracket> taxBrackets;

    // constructor
    public HardwiredTax() {
        // creating and filling the list of tax brackets
        List<TaxBracket> brackets = new ArrayList<>();
        for (int i = 0; i < LIMITS.length; i++) {
            brackets.add(new TaxBracket(BigDecimal.valueOf(LIMITS[i]),
                    BigDecimal.valueOf(COEFF_R[i]), BigDecimal.valueOf(COEFF_N[i])));
        }
        // the stored list is not modifiable
        taxBrackets = List.copyOf(brackets);
    }

    // implementation of the abstract method of the parent class
    @Override
    protected List<TaxBracket> getTaxBrackets() {
        return taxBrackets;
    }
}
  • Lines 9–11: The class [HardwiredTax] hardcodes the data needed to calculate the tax. These are class fields ([static]) [final]: they are shared by all instances and cannot be replaced by other arrays. By convention, the names of the constants ([static final]) are written in uppercase;
  • lines 17–26: the constructor uses this data to build the list of tax brackets. [BigDecimal.valueOf(double)] (lines 21–22) converts a [double] to a [BigDecimal] via its shortest decimal representation: [BigDecimal.valueOf(0.068)] is exactly 0.068. Do not use the constructor [new BigDecimal(0.068)], which reproduces all the decimal places of the approximated binary value (0.068000000000000004884981308350688…);
  • line 25: [List.copyOf] returns a read-only copy of the list: no class will be able to modify the scale;
  • lines 29–32: the parent class’s abstract method is implemented: it returns the list of brackets.

The next chapter will add other classes derived from [AbstractTax], which will read the brackets from a text file or from JSON: they will only need to provide the [getTaxBrackets] method.

A test program could be the following [Main.java]:

// interactive tax calculation program
// the user types three pieces of data on the keyboard: married nbChildren salary
// the program then displays the tax to pay
import tax.HardwiredTax;
import tax.ITax;

static final String SYNTAX = """
        syntax: Married NbChildren Salary
        Married: y for married, n for not married
        NbChildren: number of children
        Salary: annual salary in euros""";

void main() {
    // creation of an ITax object
    ITax tax = new HardwiredTax();

    // input loop
    while (true) {
        // we ask for the tax calculation parameters
        String parameters = IO.readln("Tax calculation parameters in the format: "
                + "Married (y/n) NbChildren Salary, or nothing to stop: ");
        // end of standard input or empty line: we stop
        if (parameters == null || parameters.isBlank()) {
            break;
        }
        // checking the number of fields of the typed line
        String[] fields = parameters.strip().split("\\s+");
        if (fields.length != 3) {
            IO.println(SYNTAX);
            continue;
        }
        // checking the validity of the parameters
        // married
        String married = fields[0].toLowerCase();
        if (!married.equals("y") && !married.equals("n")) {
            IO.println(SYNTAX + "\nIncorrect married argument: type y or n");
            continue;
        }
        // nbChildren
        int nbChildren = positiveInteger(fields[1]);
        if (nbChildren < 0) {
            IO.println(SYNTAX + "\nIncorrect NbChildren argument: type a positive or zero integer");
            continue;
        }
        // salary
        int salary = positiveInteger(fields[2]);
        if (salary < 0) {
            IO.println(SYNTAX + "\nIncorrect salary argument: type a positive or zero integer");
            continue;
        }
        // the parameters are correct: we calculate the tax
        IO.println("Tax=" + tax.calculate(married.equals("y"), nbChildren, salary) + " euros");
    }
}

// returns the positive or zero integer represented by the string, -1 if the string does not represent one
int positiveInteger(String input) {
    try {
        int value = Integer.parseInt(input);
        return value >= 0 ? value : -1;
    } catch (NumberFormatException ex) {
        return -1;
    }
}

The program above allows the user to perform repeated tax calculation simulations.

  • Lines 4–5: The main program is in the unnamed package; it imports the two types it needs from the [tax] package. These types must be public;
  • Lines 7–11: The help message is a constant of the implicit class, written as a text block (Java 15) delimited by three quotation marks: the string can span multiple lines, and the common indentation of the lines is removed. Since the closing quotation mark is placed at the end of the last line, the string does not end with a line break;
  • line 15: creation of a [tax] object that implements the [ITax] interface. This object is obtained by instantiating a type [HardwiredTax], a type that implements the interface [ITax]. Note that the variable [tax] was not assigned the type [HardwiredTax] but rather the type [ITax]. By writing this, we indicate that we are only interested in the [calculate] method of the [tax] object and not in the rest. To use data from a file, simply change this single line;
  • lines 18–53: the loop for tax calculation simulations;
  • lines 20–21: the three parameters required for the [calculate] method are requested in a single line entered via the keyboard. [IO.readln(message)] displays the message and then reads a line; it returns [null] if standard input is closed: the check on line 23 then terminates the program, just as an empty line would;
  • line 27: the [split] method splits the line—with leading and trailing spaces removed—into words separated by one or more space characters (regular expression ["\\s+"]);
  • Lines 40 and 46: The method [positiveInteger] (lines 57–64) verifies that the last two fields are positive integers or zero. [Integer.parseInt] throws a [NumberFormatException] exception if the string does not represent an integer: the method catches it and returns -1, which indicates an error;
  • line 52: call to the [calculate] method of the [tax] object implementing the [ITax] interface.

Here is an example of the program’s execution, launched from the [TaxV2] folder:

> java Main.java
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: q s d
syntax: Married NbChildren Salary
Married: y for married, n for not married
NbChildren: number of children
Salary: annual salary in euros
Incorrect married argument: type y or n
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: y 2 d
syntax: Married NbChildren Salary
Married: y for married, n for not married
NbChildren: number of children
Salary: annual salary in euros
Incorrect salary argument: type a positive or zero integer
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: q s d f
syntax: Married NbChildren Salary
Married: y for married, n for not married
NbChildren: number of children
Salary: annual salary in euros
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: y -1 60000
syntax: Married NbChildren Salary
Married: y for married, n for not married
NbChildren: number of children
Salary: annual salary in euros
Incorrect NbChildren argument: type a positive or zero integer
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: y 2 60000
Tax=4282 euros
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: y 3 60000
Tax=2959 euros
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: n 0 60000
Tax=11519 euros
Tax calculation parameters in the format: Married (y/n) NbChildren Salary, or nothing to stop: 

The tax amounts are calculated correctly: 4,282 euros for a married taxpayer with two children and an annual salary of 60,000 euros (line 26), and 2,959 euros with three children (line 28). An unmarried taxpayer with no children pays 11,519 euros for the same salary (line 30).

Compared to Version 1, the calculation is now encapsulated in classes behind an interface: the dialog program only recognizes the [ITax] interface. Subsequent versions will take advantage of this separation: Version 3 (next chapter) will change the data source by deriving new classes from [AbstractTax], and then the application will be organized into layers (Chapter 6).