JavaRush /Java Blog /Random EN /Coffee break #128. Java Records Guide

Coffee break #128. Java Records Guide

Published in the Random EN group
Source: abhinavpandey.dev In this tutorial, we will cover the basics of using Records in Java. Records were introduced in Java 14 as a way to remove boilerplate code around the creation of Value objects while taking advantage of immutable objects. Coffee break #128.  Java Records Guide - 1

1. Basic concepts

Before we get into the entries themselves, let's look at the problem they solve. To do this, we will have to remember how value objects were created before Java 14.

1.1. Value objects

Value objects are an integral part of Java applications. They store data that needs to be transferred between application layers. A value object contains fields, constructors, and methods for accessing those fields. Below is an example of a value object:
public class Contact {
    private final String name;
    private final String email;

    public Contact(String name, String email) {
        this.name = name;
        this.email = email;
    }

    public String getName() {
        return name;
    }

    public String getEmail() {
        return email;
    }
}

1.2. Equality between Value objects

Value objects can also provide a way to compare them for equality. By default, Java compares the equality of objects by comparing their memory address. However, in some cases, objects containing the same data may be considered equal. To implement this, we can override the equals and .hashCode methods . Let's implement them for the Contact class :
public class Contact {

    // ...

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Contact contact = (Contact) o;
        return Object.equals(email, contact.email) &&
                Objects.equals(name, contact.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, email);
    }
}

1.3. Immutability of Value objects

Value objects must be immutable. This means that we must limit the ways we can change the fields of an object. This is advisable for the following reasons:
  • To avoid the risk of accidentally changing the field value.
  • To ensure that equal objects remain the same throughout their lives.
Since the Contact class is already immutable, we now:
  1. made the fields private and final .
  2. provided only a getter for each field (no setters ).

1.4. Registering Value objects

Often we need to register the values ​​contained in objects. This is done by providing a toString method . Whenever an object is registered or printed, the toString method is called . The easiest way here is to print the value of each field. Here's an example:
public class Contact {
    // ...
    @Override
    public String toString() {
        return "Contact[" +
                "name='" + name + '\'' +
                ", email=" + email +
                ']';
    }
}

2. Reduce templates with Records

Since most value objects have the same needs and functionality, it would be nice to simplify the process of creating them. Let's look at how recordings help achieve this.

2.1. Converting the Person class to Record

Let's create a Contact class entry that has the same functionality as the Contact class defined above.
public record Contact(String name, String email) {}
The record keyword is used to create a Record class . Records can be processed by the caller in the same way as a class. For example, to create a new entry instance, we can use the new keyword .
Contact contact = new Contact("John Doe", "johnrocks@gmail.com");

2.2. Default Behavior

We've reduced the code to one line. Let's list what it includes:
  1. The name and email fields are private and final by default.

  2. The code defines a “canonical constructor” that takes fields as parameters.

  3. Fields are accessible through getter-like methods - name() and email() . There is no setter for fields, so the data in the object becomes immutable.

  4. Implemented the toString method to print the fields just like we did for the Contact class .

  5. Implemented equals and .hashCode methods . They include all fields, just like the Contact class .

2.3 Canonical constructor

The default constructor takes all fields as input parameters and sets them to fields. For example, the default Canonical Constructor is shown below:
public Contact(String name, String email) {
    this.name = name;
    this.email = email;
}
If we define a constructor with the same signature in the recording class, it will be used instead of the canonical constructor.

3. Working with records

We can change the behavior of the entry in several ways. Let's look at some use cases and how to achieve them.

3.1. Overriding default implementations

Any default implementation can be changed by overriding it. For example, if we want to change the behavior of the toString method , then we can override it between the curly braces {} .
public record Contact(String name, String email) {
    @Override
    public String toString() {
        return "Contact[" +
                "name is '" + name + '\'' +
                ", email is" + email +
                ']';
    }
}
Similarly, we can override the equals and hashCode methods .

3.2. Compact construction kits

Sometimes we want constructors to do more than just initialize fields. To do this, we can add the necessary operations to our entry in the Compact Constructor. It is called compact because it does not need to define field initialization or parameter list.
public record Contact(String name, String email) {
    public Contact {
        if(!email.contains("@")) {
            throw new IllegalArgumentException("Invalid email");
        }
    }
}
Note that there is no list of parameters, and initialization of name and email occurs in the background before the check is performed.

3.3. Adding Constructors

You can add multiple constructors to a record. Let's look at a couple of examples and limitations. First, let's add new valid constructors:
public record Contact(String name, String email) {
    public Contact(String email) {
        this("John Doe", email);
    }

    // replaces the default constructor
    public Contact(String name, String email) {
        this.name = name;
        this.email = email;
    }
}
In the first case, the default constructor is accessed using the this keyword . The second constructor overrides the default constructor because it has the same parameter list. In this case, the entry itself will not create a default constructor. There are several restrictions on constructors.

1. The default constructor should always be called from any other constructor.

For example, the code below will not compile:
public record Contact(String name, String email) {
    public Contact(String name) {
        this.name = "John Doe";
        this.email = null;
    }
}
This rule ensures that fields are always initialized. It is also guaranteed that the operations defined in the compact constructor are always executed.

2. It is not possible to override the default constructor if a compact constructor is defined.

When a compact constructor is defined, a default constructor is automatically created with initialization and compact constructor logic. In this case, the compiler will not allow us to define a constructor with the same arguments as the default constructor. For example, in this code the compilation will not happen:
public record Contact(String name, String email) {
    public Contact {
        if(!email.contains("@")) {
            throw new IllegalArgumentException("Invalid email");
        }
    }
    public Contact(String name, String email) {
        this.name = name;
        this.email = email;
    }
}

3.4. Implementing Interfaces

As with any class, we can implement interfaces in records.
public record Contact(String name, String email) implements Comparable<Contact> {
    @Override
    public int compareTo(Contact o) {
        return name.compareTo(o.name);
    }
}
Important note. To ensure complete immutability, records cannot be inherited. Entries are final and cannot be expanded. They also cannot extend other classes.

3.5. Adding Methods

In addition to constructors, which override methods and interface implementations, we can also add any methods we want. For example:
public record Contact(String name, String email) {
    String printName() {
        return "My name is:" + this.name;
    }
}
We can also add static methods. For example, if we want to have a static method that returns a regular expression against which we can check email, then we can define it as shown below:
public record Contact(String name, String email) {
    static Pattern emailRegex() {
        return Pattern.compile("^[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,6}$", Pattern.CASE_INSENSITIVE);
    }
}

3.6. Adding fields

We cannot add instance fields to a record. However, we can add static fields.
public record Contact(String name, String email) {
    private static final Pattern EMAIL_REGEX_PATTERN = Pattern
            .compile("^[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,6}$", Pattern.CASE_INSENSITIVE);

    static Pattern emailRegex() {
        return EMAIL_REGEX_PATTERN;
    }
}
Note that there are no implicit restrictions in static fields. If necessary, they may be publicly available and not final.

Conclusion

Records are a great way to define data classes. They are much more convenient and powerful than the JavaBeans/POJO approach. Because they are easy to implement, they should be preferred over other ways of creating value objects.
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION