Chapter THREE
Working with Records and Enums


Exam Objectives

Create classes and records, and define and use instance and static fields and methods, constructors, and instance and static initializers.
Create and use enum types with fields, methods, and constructors.

Chapter Content


Records

Introducing Records

Records provide a more concise way to declare classes that are primarily intended as simple data carriers. You can think of records as a special type of class that is specifically designed to store immutable data, kind of like a sturdy, tamper-proof safe for your information.

But what exactly are records? Well, essentially, a record is a final class that automatically generates a constructor, private final fields for the parameters you define, and implementations of the equals(), hashCode(), and toString() methods based on those fields. This means that records give you a shorthand way to create a class that encapsulates data, without having to write a lot of repetitive boilerplate code.

Here’s a diagram that shows the basic structure and components of a record declaration:

┌─────────────────────────────────────────────────┐
│ public record Person(String name, int age) {    │
│                                                 │
│  ┌─────────────────────────────────────────┐    │
│  │ Implicit Components                     │    │
│  │ ● Private final fields                  │    │
│  │ ● Public constructor                    │    │
│  │ ● Public accessor methods               │    │
│  │ ● equals(), hashCode(), toString()      │    │
│  └─────────────────────────────────────────┘    │
│                                                 │
│  ┌─────────────────────────────────────────┐    │
│  │ Customizable Components                 │    │
│  │ ● Compact constructor                   │    │
│  │ ● Additional methods                    │    │
│  │ ● Static fields and methods             │    │
│  └─────────────────────────────────────────┘    │
│                                                 │
└─────────────────────────────────────────────────┘

And here’s an example of a record definition:

record Person(String name, int age) {}

With just this one line, we’ve defined a Person record that has two fields: name and age. The record automatically generates a constructor that takes those fields as parameters, so we can create instances of the record like this:

Person john = new Person("John Doe", 30);

One important thing to understand about records is that they are not just a shorthand for writing classes. While they do provide a more concise syntax, records have some unique characteristics that set them apart from regular classes. One of the most significant is that records are implicitly final, which means they cannot be extended by other classes. This reinforces their purpose as simple, immutable data carriers.

Additionally, records are implicitly static when they are declared as nested types. This means that they do not have a reference to the instance of the enclosing class:

public class OuterClass {

    // Nested record
    public record NestedRecord(int value) {
    }

    // ...
}

In the example, NestedRecord is a record nested inside OuterClass. It is implicitly static, meaning it can be instantiated without an instance of OuterClass:

OuterClass.NestedRecord nestedRecord = new OuterClass.NestedRecord(8);

So, when should you use a record instead of a class? Records are ideal for situations where you need to represent a simple, immutable data structure, like a point with x and y coordinates, or a person with a name and age. In these cases, using a record can save you a lot of time and reduce the verbosity of your code:

record Point(int x, int y) {}

On the other hand, if you need a more complex data structure that requires additional behavior or mutable state, a regular class is still the way to go. Records are not meant to replace classes entirely, but rather to complement them by providing a streamlined solution for a specific use case.

Record Immutability

One of the defining characteristics of records is their immutability. When we say that records are immutable, it means that once an instance of a record is created, its state cannot be changed. This is enforced by the fact that all fields in a record are implicitly final, which means they must be initialized when the record is instantiated and cannot be modified afterward.

record Person(String name, int age) {
    void birthday() {
        age++; // Compile-time error: Cannot assign a value to final variable age
    }
}

Since records are designed to be immutable, there’s no way to make individual fields mutable. If you find yourself needing to modify the values of fields after instantiation, it’s a good indication that a record might not be the right choice for your use case, and a regular class would be more appropriate.

Regarding immutability, there are some reasons why records are often preferred over mutable objects:

  1. Records are inherently thread-safe because their state cannot be modified after creation, eliminating the risk of concurrent access issues.
  2. Records are simpler to reason about and less prone to bugs because their state remains constant throughout their lifetime.
  3. Records can be safely shared and reused without the need for defensive copying.

However, it’s important to note that immutability in records only applies to the record itself and its fields. If a record contains a reference to a mutable object, such as a list or an array, that object can still be modified even though the record itself is immutable:

record Numbers(List<Integer> values) {}

Numbers numbers = new Numbers(new ArrayList<>(List.of(1, 2, 3)));
numbers.values().add(4); // The list can still be modified

In this example, even though the Numbers record is immutable, the List stored in its values field can still be modified because it is a mutable object.

So, when designing your records, it’s important to consider the immutability of the objects they contain. If you want to ensure complete immutability, you should use immutable objects or defensive copying techniques when storing mutable objects inside your records.

Initializing Records

Previously, you saw how records automatically generate a constructor based on the record components. This default constructor is sufficient for many use cases, but there are times when you might need more control over the initialization process. Fortunately, records provide several ways to customize the constructor and add your own initialization logic.

The long constructor, also known as the canonical constructor, is the default constructor generated by the record. It takes all the record components as parameters in the order they are declared.

record Person(String name, int age) {}

Person john = new Person("John Doe", 30);

In this example, the Person record has a default constructor that takes a String for the name and an int for the age.

If you need to validate or preprocess any of the fields before they are assigned, you can use a compact constructor. This constructor does not specify parameters explicitly. Instead, you write the constructor without parameters, and the compiler understands that it should use the record’s parameters. Inside the compact constructor, you can add validation or transformation logic. However, unlike the canonical constructor, you don’t assign values to the fields directly, this is handled automatically.

Here’s an example of a compact constructor for the Person record:

record Person(String name, int age) {
    public Person {
        if (age < 0) {
            throw new IllegalArgumentException("Age cannot be negative");
        }
    }
}

The constructor body contains a validation check to ensure that the age is not negative. If an invalid age is provided, an IllegalArgumentException is thrown.

Records also support constructor overloading, which means you can define multiple constructors with different parameter lists. This can be useful when you want to provide alternative ways to initialize a record.

However, each of these constructors must delegate to the canonical constructor (either directly or indirectly through another custom constructor) to ensure all fields are initialized. This is usually done with the this() call, passing the necessary parameters.

Here’s an example of a custom/overloaded constructor:

record Person(String name, int age) {
    public Person(String name) {
        this(name, 0);
    }
}

Person john = new Person("John Doe", 30);
Person jane = new Person("Jane Smith");

In this example, we’ve added an overloaded constructor that takes only the name parameter. Inside the constructor, we call the canonical constructor using this(), passing the provided name and a default age of 0.

This approach allows you to:

Customizing Records

While records are straightforward to use out of the box, Java provides a few ways to customize them to fit your needs.

Instance Methods

Although records are primarily designed to carry data, this doesn’t mean they can’t have behavior. Just like regular classes, you can add instance methods to records to encapsulate logic that operates on the record’s components. Here’s an example:

public record Point(int x, int y) {
    public double distance(Point other) {
        int dx = x - other.x;
        int dy = y - other.y;
        return Math.sqrt(dx * dx + dy * dy);
    }
}

In this case, the Point record has an instance method distance() that calculates the Euclidean distance between itself and another Point. The method can access the record’s components x and y directly.

You can also override methods inherited from the Object class, such as equals(), hashCode(), and toString(). By default, records provide sensible implementations of these methods based on the record’s components, but you can customize them if needed:

public record Person(String name, int age) {
    @Override
    public String toString() {
        return name + " (" + age + " years old)";
    }
}

In this example, toString() is overridden to offer a more human-readable representation of a Person record.

However, when overriding equals() and hashCode(), be careful to maintain consistency with the automatically generated implementations. The record’s components should be included in the equality comparison and hash code computation to ensure that two records with the same component values are considered equal and have the same hash code.

Nested Types

Records can contain nested classes, interfaces, annotations, enums, and even other records. This allows you to group related types together within the record, enhancing encapsulation and readability. For example:

public record Employee(String name, Department department) {
    public class Department { 
        // Implementation of the class
    }
    
    public static record Manager(String name) {
        // Additional fields and methods for managers
    }
}

In this example, the Employee record has a nested class Department representing, for example, the different departments an employee can belong to. It also has a nested static record Manager, which may have additional fields and methods specific to managers.

Nested types declared within a record are implicitly static, so they can be accessed using the record name followed by the type name, like Employee.Department or Employee.Manager.

Generics and Type Parameters

Records can be generic and accept type parameters, just like classes and interfaces. This allows you to create records that can work with different data types while still maintaining type safety. Here’s an example of a generic Pair record:

public record Pair<T, U>(T first, U second) { }

You can then create instances of the Pair record with specific types:

Pair<String, Integer> nameAge = new Pair<>("Alice", 30);

Generic records work seamlessly with Java’s type system, including wildcards, bounded type parameters, and type inference. We’ll talk more about generics in another chapter.

Local Records

In addition to being declared at the class level, records can also be declared locally within methods. This can be handy when you need a temporary data structure with a limited scope. Here’s an example:

public void processCoordinates() {
    record Coordinate(int x, int y) { }
    
    Coordinate point1 = new Coordinate(10, 20);
    Coordinate point2 = new Coordinate(30, 40);
    
    // Process the coordinates...
}

The Coordinate record is declared inside the processCoordinates() method and is only accessible within that method.

Implementing Interfaces

Although records are primarily designed for data encapsulation, they can still implement interfaces. This allows records to satisfy contracts and be used in contexts where a specific interface is required. Here’s an example:

public interface Drawable {
    void draw();
}

public record ColoredPoint(int x, int y, String color) implements Drawable {
    @Override
    public void draw() {
        System.out.println("Drawing a " + color + " point at (" + x + ", " + y + ")");
    }
}

In this case, the ColoredPoint record implements the Drawable interface and provides an implementation for the draw() method.

Restrictions

First of all, records cannot extend classes or be extended by other classes. This restriction enforces the idea that records are standalone data carriers and not part of an inheritance hierarchy. However, records can implement interfaces, as shown earlier.

Another important restriction is that records do not allow additional instance fields outside of the ones defined in the record declaration. The record’s components are the only instance fields allowed. For example:

public record Point(int x, int y) {
    private int z; // Compilation error: field declaration must be static
}

Adding extra instance fields like z in this example will result in a compilation error. The purpose of this restriction is to maintain the record’s immutability and keep its state tied solely to its components.

The need for additional instance fields indicates that a regular class may be more suitable than a record. Records are meant to be lightweight data carriers, not complex objects with mutable state.

However, it’s important to note that the error message specifically mentions that the field declaration must be static. So, if we modify the example to make z a static field:

public record Point(int x, int y) {
    private static int z; // Compiles successfully
}

This version of the Point record will compile without issues. However, keep in mind that static fields are shared across all instances of the record, so they don’t contribute to the record’s individual state.

Another thing to keep in mind is that records do not support instance initializers. If you try to add an instance initializer block to a record, like this:

public record Point(int x, int y) {
    // Instance initializer block
    { 
        System.out.println("Initializing Point...");
    } // Compiler error: instance initializers not allowed in records
}

The Java compiler will throw an error. The reason behind this restriction is that records are designed to be simple and immutable, and instance initializers can introduce complex initialization logic that may violate these principles.

If you need to perform additional initialization logic, you can use a compact constructor instead:

public record Point(int x, int y) {
    public Point {
        System.out.println("Initializing Point...");
    }
}

A compact constructor allows you to execute code at the time of the record’s instantiation while still ensuring that the record’s components are properly initialized.

However, static initializers are allowed. The following will compile without errors:

public record Point(int x, int y) {
    // Static initializer block
    static { 
        System.out.println("Initializing Point...");
    }
}

Why?

Static initializers are allowed in records for the same reasons they are allowed in other classes: to initialize static fields or to perform static initialization blocks that run when the class is loaded.

So while you can add instance methods and static fields and static initializer blocks, you can’t add instance fields or instance initializer blocks, because these could break immutability.

Remember, records are not a replacement for regular classes but rather a complementary feature for specific use cases where immutable data carriers are needed.

Enums

Introducing Enums

In Java, an enumeration (or enum) is a special type of class used to define a set of predefined constants. It’s a way to give names to numeric values, making your code more readable and maintainable.

Think of an enum like a VIP list for an exclusive event. The list (enum) defines who’s allowed in (the predefined constants), but each person on the list can also have their own unique attributes (fields) and actions they can perform (methods). The process of adding someone to the list with their specific attributes is similar to using a constructor in an enumeration.

Let’s say you’re creating an application to manage a pet shop. You might have a variable to represent the type of animal:

String animalType;
//...
if(animalType.equals("DOG")) { 
    // process dog
} else if(animalType.equals("CAT")) {
    // process cat
} else if(animalType.equals("BIRD")) {
    // process bird
}

But this approach has some problems. First, it’s error-prone. What if you mistype “DOG” as “DIG” somewhere? The compiler won’t catch that. Second, it’s not very readable. Someone reading this code might not immediately know what “BIRD” means in the context of your application.

Here’s where enums come in:

enum AnimalType {
    DOG, CAT, BIRD
}

Now you can use the enum like this:

AnimalType animalType;
//...  
if(animalType == AnimalType.DOG) {
    // process dog
} else if(animalType == AnimalType.CAT) {
    // process cat  
} else if(animalType == AnimalType.BIRD) {
    // process bird
}

If you mistype DOG, the compiler will catch it. And it’s much more readable.

So in essence, enums provide a way to define a set of named constants, which can make your code more readable, maintainable, and less error-prone.

Here’s a diagram that shows the basic structure and components of an enum declaration:

┌─────────────────────────────────────────────────┐
│ public enum DayOfWeek {                         │
│     MONDAY, TUESDAY, WEDNESDAY, THURSDAY,       │
│     FRIDAY, SATURDAY, SUNDAY;                   │
│                                                 │
│  ┌─────────────────────────────────────────┐    │
│  │ Implicit Components                     │    │
│  │ ● ordinal() : int                       │    │
│  │ ● name() : String                       │    │
│  │ ● values() : DayOfWeek[]                │    │
│  │ ● valueOf(String) : DayOfWeek           │    │
│  └─────────────────────────────────────────┘    │
│                                                 │
│  ┌─────────────────────────────────────────┐    │
│  │ Customizable Components                 │    │
│  │ ● Fields                                │    │
│  │ ● Constructors                          │    │
│  │ ● Methods                               │    │
│  └─────────────────────────────────────────┘    │
│                                                 │
└─────────────────────────────────────────────────┘

Declaring an Enum

Declaring an enum is similar to declaring a class, but you use the enum keyword instead of class:

public enum AnimalType {
    DOG, CAT, BIRD
}

Each constant (DOG, CAT, BIRD) is implicitly public, static, and final. The convention is to use all caps for their names.

It’s important to note that enums can only have public or default (package-private) access when declaring outside of a class, they cannot be declared with protected or private access. If an enum is defined within a class, it can have any access level that a regular inner class can have.

Here’s an example:

public class PetStore {
    // This is okay
    private enum EmployeeLevel {
        TRAINEE, MANAGER, DIRECTOR
    }
    
    // This is okay
    protected enum AnimalBreed {
        LABRADOR, SIAMESE, PARROT
    }
}

// This is okay  
enum AnimalType {
    DOG, CAT, BIRD
}

// This will not compile
private enum FoodType {
    KIBBLE, CANNED, SEEDS
}

As you can see, enums declared within a class (EmployeeLevel) can have any access modifier that a regular inner class can have. And when an enum is declared outside a class, it must be public or have default access, it cannot be private (FoodType).

Also, if you declare an enum in its own file, the enum name should match the filename.

But enums aren’t just a list of constants. They can have constructors, methods, and fields, just like a regular class. However, the constructor of an enum is always private, either explicitly or implicitly. By default, if no access modifier is specified, the constructor is implicitly private. Enum constructors cannot be public or protected. This is because you don’t create instances of an enum using new. Instead, the instances are predefined.

public enum AnimalType {
    DOG("Dog"), CAT("Cat"), BIRD("Bird");

    private String displayName;

    AnimalType(String displayName) {
        this.displayName = displayName;  
    }

    public String getDisplayName() {
        return displayName;
    }
}

In this example, each constant is created with a display name, which is passed to the constructor. The constructor is private, which is the default for enums. Each constant is essentially an instance of the enum class.

This answers a few common questions about enums:

Another important thing to note is that all enums implicitly extend java.lang.Enum. This is a special class in Java that provides some built-in methods for enums.

Because of this implicit extension, an enum can’t extend any other class. However, it can implement interfaces.

Special Methods of an Enum

An enum class implicitly declares some public static methods that are quite useful and that are not obvious at first sight, like the values() and valueOf() methods.

For example, assuming we have this enum:

enum Season {
    WINTER, SPRING, SUMMER, FALL;
}

The public static T[] values() method returns an array containing all the constants of the enum class, in the same order they are declared. This method is commonly used to iterate over all the constants. For example:

for(Season s : Season.values()) {
    System.out.println(s);
}

Outputs:

WINTER
SPRING
SUMMER
FALL

You might be wondering where this method comes from, as it is not mentioned on the javadoc for the enum class. The answer is that the Java compiler automatically adds it to the enum class during compilation. So in a way, it’s like syntactic sugar provided by the language.

The public static T valueOf(String) method returns the enum constant with the specified name. The name must match exactly an identifier used to declare the constant in the enum class. For example:

Season s = Season.valueOf("SUMMER");

Apart from those, each enum constant also has a name() method to get the name of the constant as declared in the enum, and an ordinal() method to get its position in the declaration order (starting from 0). For example:

Season.WINTER.name();    // "WINTER"
Season.SPRING.ordinal(); // 1

The compareTo(E o) method is another important method available for all enum types. This method compares the enum constant with another enum constant of the same enum type based on their ordinal values. It returns a negative integer, zero, or a positive integer if this enum constant is considered less than, equal to, or greater than the specified enum constant, respectively. This method allows enum constants to be used in sorted collections or for any comparison-based operations. For example:

Season.WINTER.compareTo(Season.SUMMER); // Returns a negative number
Season.FALL.compareTo(Season.SPRING);   // Returns a positive number
Season.SPRING.compareTo(Season.SPRING); // Returns 0

It’s worth noting that the natural ordering provided by compareTo() for enum constants is based on their declaration order, which may not always be the most meaningful ordering for your specific use case. In such situations, you might need to implement a custom Comparator for your enum type.

Here’s a table that not only summarizes all these methods but also provides a bit more depth into how they can be used and what to be aware of when using them:

Method Description Return Type Remarks
values() Returns an array containing all of the enum constants in the order they’re declared. EnumType[] Useful for iterating over all constants in an enum.
valueOf(String name) Returns the enum constant of the specified name. EnumType Throws IllegalArgumentException if the specified name doesn’t match any of the enum constants.
name() Returns the name of this enum constant, exactly as declared in its enum declaration. String Identical to calling toString(), but name() is final and cannot be overridden.
ordinal() Returns the ordinal of this enumeration constant (its position in the enum declaration, where the initial constant is assigned an ordinal of zero). int Can be used to associate array or list indices directly with enum constants. If you have an array where each position corresponds to a specific enum constant, ordinal() helps in directly accessing these array elements based on the enum constants’ order.
compareTo(E o) Compares this enum with the specified object for order. int Returns a negative integer, zero, or a positive integer as this object is less than, equal to, or greater than the specified object. The natural ordering is based on the ordinal values of the enum constants.

Customizing Enums

As mentioned before, you can add your own constructors to an enum class. The only requisite is that the constructors must be private or package-private. However, you can also add fields and methods to customize the enum class.

Let’s say we want to associate a minimum and maximum average temperature to each season:

public enum Season {
    WINTER(-5, 10), 
    SPRING(11, 20), 
    SUMMER(21, 35), 
    FALL(5, 20);

    private int minTemp;
    private int maxTemp;
    
    Season(int minTemp, int maxTemp) {
        this.minTemp = minTemp;
        this.maxTemp = maxTemp;
    }
    
    public int getMinTemp() { return minTemp; }
    public int getMaxTemp() { return maxTemp; }
}

The example adds a constructor that receives the temperatures. It’s package-private, as required. Also, it declares the fields to store the values and public getters for them.

With this, we can consult the temperatures associated with a season:

Season.WINTER.getMaxTemp(); // 10

We can add any other fields and methods we want to make our enum more interesting.

The only thing we have to remember is to declare the enum constants first in the class. We can declare fields and constructors in the middle, but no other constants below them, or we’ll get a compile error.

The following example attempts to declare fields in the middle of enum constants. This will lead to a compile error:

public enum Season {
    WINTER(-5, 10), 
    SPRING(11, 20),
    
    private int minTemp; // Compile error: enum constant expected here
    private int maxTemp;

    SUMMER(21, 35), 
    FALL(5, 20);
    
    Season(int minTemp, int maxTemp) {
        this.minTemp = minTemp;
        this.maxTemp = maxTemp;
    }
    
    public int getMinTemp() { return minTemp; }
    public int getMaxTemp() { return maxTemp; }
}

So be careful, declaring enum constants after any fields or constructors is a common pitfall when defining enums with customized constructors and fields.

Key Points

Practice Questions

1. Consider the following record definition:

public record Employee(String name, int age) {}

Which of the following statements is true about the Employee record?

A) The Employee record explicitly defines a public constructor that initializes its fields.
B) The fields name and age can be reassigned to new values after an Employee object is created.
C) The Employee record implicitly creates a public constructor and private final fields for name and age.
D) It is mandatory to define getters for the fields name and age in the Employee record.

2. Given the record definition below:

public record Account(String id, double balance) {}

Which statement accurately describes the immutability of records?

A) The balance field can be modified using a public setter method within the Account record.
B) Once an Account object is created, its id and balance cannot be changed.
C) Immutability of records can be bypassed by you define custom setter methods for the id and balance fields.
D) Records allow field values to be modified if accessed directly, without using setter methods.

3. Consider the following record declaration:

public record Product(int id, String name, double price) {}

How can you correctly initialize an instance of the Product record?

A) Product p = new Product();
B) Product p = Product(101, "Coffee", 15.99);
C) Product p = {101, "Coffee", 15.99};
D) Product p = new Product(101, "Coffee", 15.99);

4. Consider a record that needs to implement the Comparable interface to allow sorting based on one of its fields. Given the following record definition:

public record Item(int id, String name, double price) implements Comparable<Item> {
    public int compareTo(Item other) {
        return Double.compare(this.price, other.price);
    }
}

Which statement correctly describes how records can be customized by implementing interfaces?

A) Records cannot implement interfaces because they are final and immutable by design, which prevents any form of behavior customization.
B) This record correctly implements the Comparable interface, allowing Item objects to be sorted based on their price.
C) Implementing interfaces in records is restricted only to functional interfaces due to their immutable nature.
D) The compareTo method cannot be overridden in records because method overriding is not supported in record types.

5. Consider the ways to declare enums in Java. Which of the following declarations are valid? (Choose all that apply.)

A)

public enum Day {
    MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY
}

B)

enum Month {
    private JANUARY, FEBRUARY, MARCH, APRIL, MAY, JUNE, JULY, AUGUST, SEPTEMBER, OCTOBER, NOVEMBER, DECEMBER;
}

C)

protected enum Season {
    WINTER, SPRING, SUMMER, FALL
}

D)

enum Status {
    ACTIVE, INACTIVE, DELETED;

    public void printStatus() {
        System.out.println("Current status: " + this);
    }
}

6. Consider the following enum declaration:

public enum Color {
    RED, GREEN, BLUE;
}

What is the result of calling Color.GREEN.ordinal()?

A) 1
B) 2
C) 0
D) Color.GREEN

7. Consider an enum that needs to provide a custom method to display a message based on the enum constant. Which of the following implementations correctly defines such an enum?

A)

public enum Size {
    SMALL, MEDIUM, LARGE;
    public static void printSize() {
        System.out.println("The size is " + this.name());
    }
}

B)

enum Flavor {
    CHOCOLATE, VANILLA, STRAWBERRY;
    void printFlavor() {
        System.out.println("Flavor: " + Flavor.name);
    }
}

C)

protected enum Direction {
    NORTH, SOUTH, EAST, WEST;
    private printDirection() {
        System.out.println("Going " + this.toString());
    }
}

D)

public enum Season {
    WINTER, SPRING, SUMMER, FALL;
    public void printSeason() {
        System.out.println("The season is " + this.name());
    }
}

Do you like what you read? Would you consider?


Do you have a problem or something to say?

Report an issue with the book

Contact me