Language selector

Records in Java - what they can and cannot

In the previous post I wrote how to make a record and what is actually the purpose of the records. In this entry I focus on the limitations and abilities of the records.

This post has been updated for (coming) Javy 15 and JEP 384.

TL;DR:

In records, you can’t do anything that can leak the control of the state outside. (Except, not very wise IMHO, keeping mutable objects as record components.) Apart from that everything could be done with records. Could doesn’t mean should. ;-)

What records cannot?

As written in JEP 359, records are a “restricted form of a class, just like enum”. Of course, the enums and records don’t have exactly the same limitations, but some are similar in their nature.

Records can’t inherit from the other classes, the other lasses can’t inherit from the records. That’s why the code below can’t work, every line causes compilation error:

record CannotExtend(int a) extends java.util.Date {}
class ExtendingRecordDoesNotWork extends CannotExtend {}

Despite all records being objects inheriting from java.lang.Record, in record’s definition the extends can’t be used at all. And of course after being created the record classes are implicite final, that’s why a record cannot be abstract and/or declare abstract members. All fields are final as well.

Beside that records can’t declare fields which aren’t coming from the declaration / canonical constructor:

record NoExtraFields(int field1) {
	private final int field2;
}

Creating field field2 this way won’t work.

Records can’t change the value of the fields, that’s why we can’t have something like a ‘setter’

record NoSetters(int field) {
	public void field(int newValue) {
		this.field = newValue;
	}
}

Of course, some names of components are actually restricted. E.g. we can’t name a component hashCode, because then we’d have a hashCode() method and the accessor method clash.

In records, we also can’t have native methods. That makes sense, because such a method could change the state of the record, and the records are meant as immutable data containers (at lest to me). Kind of capsules for data. In this aspect they resemble enum or String. Once you have an instance in your hand, then (at least theoretically) you can’t mingle inside and replace the contents. You can create a new instance which is an exact copy or something similar (like concatenated strings).

By the way, if someone would like to use records for something that’s not strictly about data transfer or aggregation, then I think I should advise not to use records for that purpose.

What records can?

Records can implement interfaces:

record CanImplementInterfaces(int a, int b) implements Comparable<CanImplementInterfaces> {
	@Override
	public int compareTo(CanImplementInterfaces that) {
		return Objects.compare(this, that,
			Comparator.comparing(CanImplementInterfaces::a)
			    .thenComparing(CanImplementInterfaces::b));
	}
}

The above example shows one more option instantly: records can have other methods than accessors, toString(), equals() and hashCode(). They’re not limited to implementations of methods declared in interfaces:

record CustomMethods(int a, int b) {
	public void printHello(String to) {
		System.out.println("Hello " + to);
	}
}

However, we need to ask ourselves the question if the records are really meant to have methods not related to date they keep inside? I think they shouldn’t, but technically they can.

Records can have static fields and methods too:

record StaticMembers(int a) {
	private static String FOO = "bar";

	public static String bar() {
		return "foo";
	}
}

They can have just one component:

record OneField(int a) {}

What’s more, they can have no components at all:

record NoField() {}

Again: technically that’s possible. Does it make a practical sense though? Maybe an enum should be used instead?

Overwriting generated methods

A very nice property of the records is that they have some methods present right away. If they need to be overwritten them though, it’s possible. However, I’d suggest to be reasonable when doing that and of course these implementations need to obey the contracts. In particular the equals() i hashCode() should be compatible with each other. They can be overwritten the following way, for example:

record CustomEqualsAndHashCode(String string, int a) {
	//Please keep in mind that equals and hashCode need to obey their contracts!
	@Override
	public boolean equals(Object o) {
		if (this == o) return true;
		if (o == null || getClass() != o.getClass()) return false;
		CustomEqualsAndHashCode that = (CustomEqualsAndHashCode) o;
		return a == that.a &&
			Objects.equals(string, that.string);
	}

	@Override
	public int hashCode() {
		return Objects.hash(string, a);
	}
}

It’s also possible to overwrite components’ accessors. I’m not sure why would one want to do that (except creating defense copies of mutable components), but it’s possible. Here you go:

record CustomAccessor(int secret) {
	public int secret() {
		System.out.println("Here's my public secret. Please don't tell anybody...");
		return secret;
	}
}

When overwriting the accessors we shall not forget that the return type must match exactly. E.g. an accessor to component of type String has to return String type, not Object, Serializable or CharacterSequence.

Le dessert: constructors

When it comes to constructors in the records, they come in three flavours. There’s always present (because it’s created by the compiler based on record’s definition) so called ‘canonical constructor’. If the record R declares components A a, B b, C c, then the canonical constructor has parameter in exactly the same order, and it’s executed whenever you call new R(a, b, c);

Just like all generated methods, the canonical constrictor can be overwritten too. It might be handy when we need to check in the constructor if the data for the record makes some sense, because we might not want to create a record instance for such data combination at all. E.g. having four fields it might be required that the first is always different from the second, and the third from the fourth:

record CustomCanonicalConstructor(int a, int b, int c, int d) {
	public CustomCanonicalConstructor(int a, int b, int c, int d) {
		assert (a != b);
		assert (c != d);
		this.a = a;
		this.b = b;
		this.c = c;
		this.d = d;
	}
}

(I’m really wondering how the ‘let’s increase the performance by primitive obsession’ would like to do that when constructing an int[]… ;-) )

One of the very nice properties of the records is that they allow getting rid of many lines of the ceremonial code, despite it’s not an official goal from the JEP. Sadly, overwriting the canonical constructor doesn’t look nice. Just to introduce to assertions we had to repeat four parameters and four assignments. Nah, it doesn’t look good…
Luckily, to avoid that repetition, if all we need to add to the constructor is just a bit of a logic, we can use for that purpose so called ‘compact constructor’. The difference is we don’t have to repeat ‘obvious things’, meaning: parameters and assignments:

record CustomCompactConstructor(int a, int b, int c, int d) {
	public CustomCompactConstructor {
		assert (a != b);
		assert (c != d);
	}
}

Besides, there’s one mode kind of constructions available in the records. I guess it might be useful in rather non-standard situations, when we’d like to have the list of parameters in the constructor to be different from record’s header. We can initialise a record using another object and extract some data from that object. E.g. we might want to have a record (due to some weird reason) which keeps the length and the hashCode of a string. Then to avoid calling length() and hashCode() methods ‘manually’ and sending the results as arguments to the constructor, we can hide this extraction in a custom constructor.

record CustomNonCanonicalConstructor(int length, int hash) {
	public CustomNonCanonicalConstructor(String string) {
		this(string.length(), string.hashCode());
	}
}

We shall not forget that the canonical constructor exists always and that some rules apply when calling a constructor from a constructor. In particular, this(...) must be the first instruction.

Language selector