Java Record, “record is a class (at) act”

1
Clap

What is a Record?

Records in java is a final immutable class that gets the following methods by default,

  • constructor
  • getters or rather member variable accessors to be specific
  • equals, hashcode and toString

For example, if we need to write a class to maintain High and Low like below,

package com.example.demo;

public final class HiLo {
	private final int hi;
	private final int lo;
	
	public HiLo(int hi, int lo) {
		this.hi = hi;
		this.lo = lo;
	}
	
	public int getHi() {
		return this.hi;
	}
	
	public int getLo() {
		return this.lo;
	}
	
	@Override
	public int hashCode() {
		return this.hi + this.lo;
	}
	

	public boolean equals(HiLo hilo) {
		return hilo.hi == this.hi && hilo.lo == this.lo;
	}
	
	@Override
	public String toString() {
		return "High:"+ this.hi + " low:" + this.lo;
	}

}

Until java 16 (The final release of java record) we need to write code like the above, not anymore all of the above can be written in a single line,

package com.example.demo;

public record HighLow (int high, int low) { }

Record HighLow is internally a final class that has the following,

  • high and low are private final member variables of the class HighLow
  • constructor which takes high and low as arguments and assigns them to the corresponding member variable
  • accessor for high and low thru high() and low()
  • default equals compares high and low
  • default hashcode
  • default toString() which will output – HighLow[high=10, low=5]

“Record” is a class, it can be instantiated and used just like a class,

HighLow hl = new HighLow(10, 5);
System.out.println ( hl.high()); //Output: 10
System.out.println ( hl.low()); // Output: 5
System.out.println ( hl); // Output: HighLow[high=10, low=5]

Note, to access the member variable there is no “get” prefix like getHigh, it is accessed by hl.high() and hl.low(). Additionally, as all the member variables are immutable (implicitly final), there are no setters.

More on Record

As mentioned before, a record is a final class with member variables as final, hence there are no setters and there can be no additional member variables apart from what is mentioned as part of record declaration.

For example, the below is invalid

public record HighLow(int high, int low) {
     int diff; //COMPILATION ERROR
}

Constructors in record

Like in class, record can also have additional constructors however all of them will have to end up calling the constructor which takes all member variables in it, in the declared order.
Say, for example, we wanted to take only high and default low to zero, then we add an additional constructor like the below,

public record HighLow(int high, int low) {
    public HighLow(int high) {
      this(high, 0);
    }
}

public class RecordDemo {
   public static void main(String args[]) {
       HighLow hl = new HighLow(100); //Low will be zero
       HighLow hl2 = new HighLow(100, 10); 
       
   }
}

Additionally, if we require to validate the input values before setting it to the member variable, it can be done as follows,

public record HighLow(int high, int low) {
  public HighLow { //This is a constructor, assume that both high and low are input to this.
      if (high < low ) throw new RuntimeException("high must be greater than low");
  }
}

As we can note above, the constructor declaration is quite different from the normal class. record constructor has the “access modifier” followed by the class name but does not take in any arguments. Although not explicitly stated, it is implicit that the above constructor takes two arguments high and low and those arguments can be directly used in the constructor block to perform any validation. Another magic that happens here, is that although not explicitly clear, after the validation and end of the return, high and low are assigned to the corresponding member/instance variables this.high and this.low. However, we cannot explicitly set it in the constructor.

Similarly, record can also have methods like a class,

public record HighLow(int high, int low) {		
    public int diff() {
      return high - low;
    }
}

HighLow hl = new HighLow(10, 5);
int difference = hl.diff();

Records are Named Tuples

Record is Named Tuple which are better than Generic Tuples that are generally accessed as “tuple.left() and tuple.right()”.

Generic tuple does not really convey meaning clearly as the method names are always tuple.left()/right() or first()/second().
Instead in the above, it is easy to create a record and use them as hl.high() and low(), which explicitly states the purpose clearly. As we can see from the below example, we can return a Pair<int, int> but it does not express itself on what are <int, int> in a pair and whether the first int is low or the second int is low. Alternatively, returning HighLow clearly expresses itself and also can be accessed via highLow.high()/low().

//Example
public Pair<int, int> getPair() {
    return new Pair<>(10, 5);
}
record HighLow(int high, int low) 

HighLow getHighLow() {
    return new HighLow(10,5);
} 

How does record look internally?

java.lang.Record is an abstract class that all record class implicitly extends.

For example, let us consider the below record

package com.example.demo;
public record HighLow(int high, int low) { }

A simple record class mentioned above compiles to class and its internal structure can be seen below when you run “javap -p com.example.demo.HighLow”

public final class com.example.demo.HighLow extends java.lang.Record {
  private final int high;
  private final int low;
  public com.example.demo.HighLow(int, int);
  public int high();
  public int low();
  public final java.lang.String toString();
  public final int hashCode();
  public final boolean equals(java.lang.Object);
}

As we can note, record HighLow translates to a final class that extends java.lang.Record.
From the above, we can also understand that

  • Record class (HighLow) CANNOT extend any class
  • Another class CANNOT extend a (HighLow) record class.
  • Record class CANNOT be abstract
  • We also CANNOT explicitly extend a java.lang.Record class.
  • Record class CAN implement an interface
//Below is valid
public record HighLow (int high, int low) implements Runnable { 
    public void run() {
    }
};

Contextual keyword – record

In java, “record” is a “contextual keyword” and the concept of “contextual keyword” was first introduced with “var”. In my view, the contextual keyword is an innovative way of introducing new language keywords without affecting backward compatibility. It is done keeping the java community in mind and helps users to update to the latest version of java faster.

For example, we can have a method name record without issues,

public record HighLow(int high, int low) {
    public void record() { //valid to have record although record is a keyword
        System.out.println (toString());
    }
}  

Deconstruction pattern for record class

At first record in java looks like syntactic sugar which avoids boilerplate. Record adheres to certain rules like immutability, no setters, default getters, and most importantly all the member variable and their order are part of record creation. These properties of record make deconstruction of a record possible and thereby helpful in powerful pattern matching java features.

Pattern matching is yet another feature introduced in java, however, we limit to basic pattern matching in if-else conditions for demonstrating the power of record with pattern matching.

Consider a simple print method that takes in an object, here we check for instance, and then typecast it inside an “if” condition. Although it is very evident that inside the first “if” condition, Object obj is nothing but a String, we cannot use them directly and explicitly typecast it to a String and assign them before using any of the String methods. Similar is for the Record “HighLow” in the else condition.

public void print(Object obj) {
    if (obj instanceof String) {
	String str = (String) obj;
	System.out.println ( " Printing a string:"+str );
	System.out.println ( " Printing its len:"+str.length() );
     } else if ( obj instanceof HighLow) {
	HighLow hl = (HighLow) obj;
	System.out.println ( " Printing a low value:"+hl.low() );
	System.out.println ( " Printing its High value:"+hl.high());
     }
}

Pattern matching can be simplified as below, which has the same behavior. In the below type cast and conversion are done as part of the if condition itself.

public void patternDemo(Object obj) {
    if (obj instanceof String str) {
	System.out.println ( " Printing a string:"+str );
	System.out.println ( " Printing its len:"+str.length() );
    } else if ( obj instanceof HighLow hl) {
        int hi = hl.high();
        int lo = hl.low();
	System.out.println ( " Printing a low value:"+lo );
	System.out.println ( " Printing its High value:"+hi);
    }
}

Additionally, because HighLow is a record we can use the deconstruction pattern as mentioned below, in the else condition.

public void deconstructionDemo(Object obj) {
	
    if (obj instanceof String str) {
	System.out.println ( " Printing a string:"+str );
	System.out.println ( " Printing its len:"+str.length() );
		
    } else if ( obj instanceof HighLow (int hi, int lo)) { //Record Deconstruction
        System.out.println ( " Printing a low value:"+lo );
	System.out.println ( " Printing its High value:"+hi);	
    }
}

Deconstructor* is the opposite of constructor because a record class always encompasses all the variables in its declaration like “record HighLow(int high, int low), the same can be used to deconstruct it. i.e in the above else block, obj is checked for the instance of HighLow and if it is, then it will deconstruct HighLow and assign hi = high() and lo = low(). From then on hi and lo can be used in the else block freely.

It is important to note that deconstruction happens by calling the accessor method and not the member variable directly. For example, in the print method (lines 14 – 18) although the value of low = 50, it will print 40, because method low() is called during deconstruction, and lo inside the if will have a value of 40 as a result of calling low() method.

package com.example.demo;

public record HighLow (int high, int low) { 
	
    public int low() {
        System.out.println( " CALLED " ); // This will be printed
	return this.low - 10;
    }
	
	
    public static void print(Object obj) {
      if ( obj instanceof HighLow(int hi, int lo)) { // equal to hi = high(), low = low()
          System.out.println ( lo ); //WILL PRINT 40 and not 50
      }
    }
	
    public static void main(String args[]) {
        HighLow hl  = new HighLow(100, 50);
	print( hl);
    }
}

Note*: deconstruction of the record is still a preview feature as of java 19, hence need an additional preview flag to make it compile and work

Record with the builder pattern

Record can be built with a builder pattern, similar to the class

package com.example.demo;

public record HighLow (int high, int low) { 
		
    public static Builder builder() {
	return new Builder();
    }
    
    public static class Builder {
	int high;
	int low;
	
	public Builder high(int high) {
	    this.high = high;
	    return this;
	}
		
	public Builder low(int low) {
	    this.low = low;
	    return this;
	}
		
	public HighLow build() {
	  return new HighLow(high, low);
	}
    }

    public static void main(String args[]) {
	HighLow hl  = HighLow.builder()
			.high(100)
			.low(10)
			.build();
    }
	
	
}

Alternatively, 3rd party libraries like Lombok supports builder pattern for java record.

Many serialization libraries like jackson, jaxb, gson are slowly starting to add support for serializing and de-serializing java record from/to json, xml and so on. Not having a set method forces all of them to use just constructor-based injection instead of setters. Similarly, there are getters or accessors that do not start with the method name “get”, hence both serialization and de-serialization have to be made work with java records.

For reflection, isRecord() and getRecordComponents() are added to java.lang.Class.

isRecord() returns true if the class is declared as a record class.

getRecordComponents() returns the list of record components in the declared order of the corresponding record class. This method will return the list of components only if isRecord() is true, i.e. if the class is a record class.

Things to remember when using a record

  • No explicit setter method makes using records for any automapping like ORM (Object-relationship mapping) challenging. This may or may not change based on how the ORM library adopts records.
  • Important to remember those record member variables are only shallowly immutable. For example, although ugly it is still possible to do the below,
package com.example.demo;

public record BadPractice(int[] high, int[] low) {
    public static void main(String args[]) {
	int high[] = new int[1];
	int low[] = new int[1];
	high[0] = 10;
	low[0] = 1;
	BadPractice bp = new BadPractice(high, low);
	System.out.println ( "High:"+ bp.high[0] ); // Will print High:10
	//Changing high
	bp.high()[0] = 20;
	System.out.println ( "High:"+ bp.high[0] ); // Will print High:20
    }
}

Opinion on record

Records by nature of being immutable and rigid by declaration, it is powerful and complements when combined with other new features like pattern matching & sealed classes.

Constructors are the only way to inject dependency in the construction of a record class (no explicit setters). This makes it a powerful design choice as it by design promotes “constructor-based injection” than “setter” method-based injection.

A constructor-based injection is a better design choice (your miles may vary) as the number of dependent classes is evident, thereby helping to limit the dependency and achieve low coupling. Moreover, constructor-based injection makes it clear the mandatory dependent classes without which a class cannot operate. For example, if there are 5 setters, it will be difficult to find out what set methods which are required, optional, or the one which can take the default value. With only constructor-based dependency injection, it will be evident on the number of dependent, optional parameters which can take the default value. In the custom constructor like HighLow(int high) mentioned above, just by looking at the constructor, it will be evident that HighLow depends on 2 variables, and optionally we can set just high, and low can take a default. In other words, the record reveals the design and class intent for the consumer more clearly than a class with the setter.

Often, most of the member variables we define can be made final, however, for various reasons it can be observed that not all of them are declared final. Adding to it, JVM at runtime may still find out classes that have member variable that is mostly final (even when not declared final) and optimize those classes. Those optimizations are done at runtime, but with records as it makes all the class member variables final and the class itself cannot be extended (no class inheritance), it can make those optimization decisions much earlier out of the box and make it easier for the runtime to optimize it for performance.

Record also complements well with the new features such as primitive and value type classes planned for future versions of java. In short, value & primitive type classes do not have an identity, and primitive class also cannot be null. Record along with value type and primitive type makes it to define them easily. In case, if you are not able to follow primitive and value-type classes, don’t mind it is still a WIP feature and it will at the least take another year for it to be released and become mainstream. But the point here is that with record as a base, many new features can be built to take advantage of it.

Finally, I am of the opinion, that one-day “record” will become the de-facto way of creating a class and very soon it will surpass the number of classes in the module. Although most record is touted as data class and tuple, I believe this is useful even to create even non-data class.

I will leave with the title, literally “record is a class at act and figuratively “record is a class act”.


Also published on Medium.