Difference Between Class and Record in Java

In Java, both classes and records are used to define new data types, but they serve different purposes and have distinct characteristics. In this blog post, we will discuss the comparison between Java records (introduced in Java 16) and traditional classes with examples.

What is a Java Record? 

Java Records, introduced as a preview feature in Java 14 and made standard in Java 16, represent a significant addition to the Java language. A record is a special kind of class in Java designed specifically for holding immutable data. The primary use case for records is to act as simple data carriers without the need for additional encapsulation or behavior.

Records are a concise way to create classes that are primarily data carriers. They reduce boilerplate code significantly by automatically generating field accessors, constructors, equals(), hashCode(), and toString() methods.

Here's an example:

public record User(Long id, String firstName, String lastName, String email) {}
This single line generates:
  • Private final fields for id, firstName, lastName, and email
  • A public constructor. 
  • Public getter methods.
  • Overridden equals(), hashCode(), and toString() methods.

Traditional Java Classes 

A class in Java is a blueprint for creating objects. It can encapsulate data and methods, and is versatile in its use, supporting a wide range of programming paradigms including procedural and object-oriented programming.

Traditional classes require explicit field and method declarations. Here’s an equivalent User class:
public class User {
    private final Long id;
    private final String firstName;
    private final String lastName;
    private final String email;

    public User(Long id, String firstName, String lastName, String email) {
        this.id = id;
        this.firstName = firstName;
        this.lastName = lastName;
        this.email = email;
    }

    public Long getId() {
        return id;
    }

    public String getFirstName() {
        return firstName;
    }

    public String getLastName() {
        return lastName;
    }

    public String getEmail() {
        return email;
    }

    @Override
    public boolean equals(Object o) {
        // Custom implementation of equals
    }

    @Override
    public int hashCode() {
        // Custom implementation of hashCode
    }

    @Override
    public String toString() {
        // Custom implementation of toString
    }
}

Key Differences with Examples

1. Boilerplate Code 

Class

Traditional classes often require a significant amount of boilerplate code, including constructors, getters, equals(), hashCode(), and toString() methods.

For example:
public class User {
    private final Long id;
    private final String firstName;
    private final String lastName;
    private final String email;

    public User(Long id, String firstName, String lastName, String email) {
        this.id = id;
        this.firstName = firstName;
        this.lastName = lastName;
        this.email = email;
    }

    public Long getId() {
        return id;
    }

    public String getFirstName() {
        return firstName;
    }

    public String getLastName() {
        return lastName;
    }

    public String getEmail() {
        return email;
    }

    @Override
    public boolean equals(Object o) {
        // Custom implementation of equals
    }

    @Override
    public int hashCode() {
        // Custom implementation of hashCode
    }

    @Override
    public String toString() {
        // Custom implementation of toString
    }
}

Record

Records automatically generate this boilerplate. The compiler automatically provides a public constructor, as well as implementations of equals(), hashCode(), and toString() methods based on the record components.
public record User(Long id, String firstName, String lastName, String email) {}
In the above example, the compiler automatically generates a constructor, getters, equals(), hashCode(), and toString() methods for the User record.

2. Immutability

Class

Classes can define mutable objects. The fields of a class can be modified after the object is created unless they are explicitly declared as final.

Example: Classes can be designed to be immutable, but it's up to the developer.

public class User {
    // Same as previous example
    // Setters are not provided, making this implementation immutable
}

Record

Records are inherently immutable. The state of a record is defined at the time of its creation and cannot be changed later. All fields in a record are final.

User user = new User(1L, "John", "Doe", "[email protected]");
// user.id = 2L; // This would result in a compilation error

3. Inheritance

Class

A class can extend another class and can be extended by other classes, supporting inheritance.

Example: PremiumUser class extends User class:

public class PremiumUser extends User {
    private final String membershipLevel;

    public PremiumUser(Long id, String firstName, String lastName, String email, String membershipLevel) {
        super(id, firstName, lastName, email);
        this.membershipLevel = membershipLevel;
    }

    public String getMembershipLevel() { return membershipLevel; }
}

Record

Records cannot extend any other class and cannot be extended, thus not supporting inheritance. They implicitly extend java.lang.Record.

Example: Records cannot extend another class and are implicitly final.

// Cannot extend any class
public record User(Long id, String firstName, String lastName, String email) {}

4. Flexibility

Class

Classes offer more flexibility in design. They can contain a mix of data and methods and can be designed to change state.

Example: Can include various behaviors and states.

public class User {
    // Same as previous example
    public void printUserInfo() {
        System.out.println("User Info: " + this.toString());
    }
}

Record

Records are less flexible but more concise. They are intended to be simple carriers of data and do not support methods that change state.

Example: Limited to data representation.

public record User(Long id, String firstName, String lastName, String email) {
    // Cannot add additional behavior or state
}

5. Field Access

Class

Classes can have private, protected, or public fields, and can include custom getter and setter methods.

Example: We'll create a User class with private fields and public getter methods, then access these fields and print the data.

class User {
    private Long id;
    private String firstName;
    private String lastName;
    private String email;

    public User(Long id, String firstName, String lastName, String email) {
        this.id = id;
        this.firstName = firstName;
        this.lastName = lastName;
        this.email = email;
    }

    public Long getId() { return id; }
    public String getFirstName() { return firstName; }
    public String getLastName() { return lastName; }
    public String getEmail() { return email; }
}

public class ClassDemo {
    public static void main(String[] args) {
        User user = new User(1L, "John", "Doe", "[email protected]");

        System.out.println("ID: " + user.getId());
        System.out.println("First Name: " + user.getFirstName());
        System.out.println("Last Name: " + user.getLastName());
        System.out.println("Email: " + user.getEmail());
    }
}

Record

Records generate public getter methods for all fields automatically, but these fields are not directly accessible; they are accessed through these getter methods.

Example: We'll create a User record, access its fields using the getter methods, and print the data.

public record User(Long id, String firstName, String lastName, String email) {}

public class RecordDemo {
    public static void main(String[] args) {
        User user = new User(1L, "John", "Doe", "[email protected]");

        System.out.println("ID: " + user.id());
        System.out.println("First Name: " + user.firstName());
        System.out.println("Last Name: " + user.lastName());
        System.out.println("Email: " + user.email());
    }
}

In both examples, we create a User instance, access its fields through the available methods (which are automatically generated in the case of the record), and then print the values. The key difference lies in the syntax and the automatic generation of these methods in records.

6. Constructor Customization

Class

Classes can have multiple constructors with different parameters.

public class User {
    public User(String email) {
        // Initialize with email only
    }

    public User(String firstName, String lastName) {
        // Initialize with first and last name
    }
}

Record

Records can have custom constructors, but they must be delegated to the primary constructor, and they must initialize all fields.

Example: In this record, there's a compact constructor performing validation. It still initializes all fields implicitly.

public record User(Long id, String firstName, String lastName, String email) {
    public User {
        if (id == null) {
            throw new IllegalArgumentException("ID cannot be null");
        }
    }
}

7. Serialization

Class

Serialization with classes can be customized using methods like writeObject and readObject.

public class User implements Serializable {
    private void writeObject(ObjectOutputStream out) throws IOException {
        // Custom serialization logic
    }

    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
        // Custom deserialization logic
    }
}

This allows for fine-grained control over the serialization process.

Record

Records are serializable as long as all their components are serializable. However, customization in the serialization process is limited.

public record User(Long id, String firstName, String lastName, String email) implements Serializable {}

In records, the serialization process is straightforward but less customizable.

Cheat Sheet

Conclusion

Java records offer a way to define data-centric, immutable types with less code. They're ideal for simple, straightforward data carriers. Traditional classes, however, provide more flexibility and control over the behavior and state of objects, making them suitable for more complex scenarios. The choice between them hinges on the specific needs of your application, whether you prioritize brevity and immutability or require more complex behavior and mutable state.

Comments