Record#with - a thought experiment
Recently, there were some interesting discussions about Record#copy()
and Record#with(...)
on the amber-spec-experts
mailing list (1, 2) which has lead me to implement both methods with a very clear & typesafe way, which I’d like to
discuss below. The code demonstrated below is available as Gist.
What led me to start this experiment was the following quote:
the idea is to add a method
Record with(Object... componentValuePairs)
injava.lang.Record
, and ask the compiler to verify that the even arguments (0, 2, 4, etc) are constant stringsProposed syntax:
var otherPerson = person.with("name", "John", "age", 17);
I thought this had to be easier and be possible in a typesafe way. So here is my suggestion:
var otherPerson = person.with(Person::name, "John").with(Person::age, 17);
What do we get? Type saftey. Better, easier to read syntax.
Can we pull this off?
Yes
It turns out, its actually possible to pull this off just as written there. Yes, Person::name
is an accessor method
and doesn’t allow us to set anything directly - but we already know how the component of the record is called when we
look at that – and we also know the type.
The question is, can we get this information from the lambda? Turns out we can.
/**
* c.f. https://stackoverflow.com/a/35223119/1360803
*/
private static SerializedLambda getSerializedLambda(Serializable lambda)
throws NoSuchMethodException, SecurityException,
IllegalAccessException, IllegalArgumentException,
InvocationTargetException {
final Method method = lambda.getClass()
.getDeclaredMethod("writeReplace");
method.setAccessible(true);
return (SerializedLambda) method.invoke(lambda);
}
We can use this to extract everything we need:
public static <T, F extends Serializable & Function<R, T>> void with(F param) {
var lambda = getSerializedLambda(param);
var name = lambda.getImplMethodName();
var signature = lambda.getImplMethodSignature();
// get descriptor, strip () of input
var typeDescriptor = signature.substring(2, signature.length());
System.out.println(name + ":" + typeDescriptor);
// if we call this with with(Person::name), we get `name:Ljava/lang/String;`
}
We get the type signature and the name of the accessor method, which is the same as the field. We strip the ()
from
the method signature to get the return type.
Putting it all together, we get this nice method:
@SuppressWarnings("unchecked")
public default <T, F extends Serializable & Function<R, T>> R with(F param, T val) {
try {
// get name & type of the changing parameter
var lambda = getSerializedLambda(param);
var name = lambda.getImplMethodName();
var signature = lambda.getImplMethodSignature();
// get descriptor, strip () of input
var typeDescriptor = signature.substring(2, signature.length());
// get record components & replace the value
var components = getClass().getRecordComponents();
var params = new Object[components.length];
for (int i = 0; i < components.length; i++) {
var component = components[i];
if (isCompatible(component, name, typeDescriptor))
params[i] = val;
else {
params[i] = component.getAccessor().invoke(this);
// accessor might modify data, so circumvent accessor
// but records don't expose their fields :(
//params[i] = getClass().getField(component.getName()).get(this);
}
}
// create new record
return (R) getClass().getConstructors()[0].newInstance(params);
} catch (NoSuchMethodException | SecurityException
| IllegalAccessException | IllegalArgumentException
| InvocationTargetException e) {
throw new RuntimeException(e);
} catch (InstantiationException e) {
throw new RuntimeException(e);
} /*catch (NoSuchFieldException e) {
throw new RuntimeException(e);
}*/
}
The full code is available as Gist. I’ve created an interface with this method as only default method, which can be implemented by any record.
And it actually works as advertised:
public static record Person(String name) implements CopyableRecord<Person> {}
var guruJava = new Person("Brian Goetz");
var guruCSharp = guruJava.with(Person::name, "Eric Lippert");
But with a few drawbacks.
The first and most obvious drawback: This only works with method references like Person::firstName
. Which is fair, I
guess. This doesn’t work:
var original = new Person("Brian", "Goetz");
var copy = original.with(p -> p.firstName(), "Eric"); // nope
In fact, my current implementation silently ignores this (which is ok for a thought experiment, not so much for production code).
Furthermore, the method I have presented above has a few commented-out lines.
params[i] = component.getAccessor().invoke(this);
// accessor might modify data, so circumvent accessor
// but records don't expose their fields :(
//params[i] = getClass().getField(component.getName()).get(this);
These also lead to the commented-out NoSuchFieldException
catch-block.
So whats the problem? Accessors can change the data. I’m not sure it would be a good or often used design feature a
programmer would want to use to return data that is invalid, instead of just some wrappers around data like
Collections#unmodifieableList
, but it can happen.
Lets look at an – arguably contrived – example:
public static record Doubling (int n, int m) implements RecordTransform<Doubling> {
public int n() { return 2 * n; }
public int m() { return 2 * m; }
}
var original = new Doubling(2, 3); // Doubling[n=2, m=3]
var copy = original.with(Doubling::n, 5); // Doubling[n=5, m=6]
So yeah. By using the accessor we double the data every time we copy the record.
Is this really a problem?
I’m not so sure. Lets say that we use libraries like ASM to access the underlying fields directly. We can still trivially easy break copying:
public static record Doubling (int n, int m) implements RecordTransform<Doubling> {
public Doubling {
n = 2 * n;
m = 2 * m;
}
}
If we copy the value of the fields, we still double them every time. And circumventing the constructor seems to be a bad idea. If someone wants to alter drastically different data in the accessor or mutate it this way in their constructors, why net let them? Sure, copying then has side-effects that might be surprising on the first glance, but are actually quite logical on second glance.
Whats actually more upsetting in my opinion is that Record#toString
doesn’t use the accessor methods and thus
displays values you can never extract from the record. It will be interesting to see how that plays out with
deconstruction patterns.
If you implement a DoublingRecord like above, what do you expect to happen when you the following?
var original = new Doubling(2,3);
let Doubling(int m, int n) = original;
var copy = Doubling(m, n);
The problems you are facing with a Record#with
or Record#copy
method are exactly the same that emerge when talking
about deconstruction. Whatever solution is chosen for deconstruction will also be applicable for those both methods.
Personally, I’d think this is a non-issue. If someone wants to implement their records that way, let them and let them deal with the fallout themselves.
My point is: If I’m able to pull this off, I’m sure the Java architects will find a way to implement
Record#with
in a much better way than by using strings to look up values and completely circumventing the type system.
It can be done inside the type system, with type safety & compiler support, and probably a lot more elegant than what
I’ve cobbled up here.
Disclaimer
This is a thought experiment. Do not use this in production. I’ve not benchmarked it, it doesn’t use
MethodHandle
/VarHandle
and is probably not suitable en masse.