I got a private mail asking, basically: why not "just" generate withers
for each component, so you could say `point.x(newX).y(newY)`.
This would be a much weaker feature than is being proposed, in several
dimensions.
1. It doesn't scale to arbitrary classes; it's a record-specific hack.
Which means value classes are left out of the cold, as are immutable
classes that can't be records or values for whatever reason. The link I
cited suggests how we're going to get to arbitrary classes; I wouldn't
support this feature if we couldn't get there.
2. It is strictly less powerful. Say you have a record with an
invariant that constraints multiple fields, such as:
record OddOrEvenPair(int a, int b) {
OddOrEvenPair {
if (a % 2 != b % 2)
throw new IllegalArgumentException();
}
}
This requires that a and b both be even, or both be odd. Note that
there's no path from (2, 2) to (3, 3); any attempt to do `new OOEP(2,
2).a(3).b(3)` will fail when we try to reconstruct the intermediate
state. You need a wither that does both a and b at once. (And we're
not going to generate the 2^n combinations.)
On 6/10/2022 8:44 AM, Brian Goetz wrote:
In
https://github.com/openjdk/amber-docs/blob/master/eg-drafts/reconstruction-records-and-classes.md
we explore a generalized mechanism for `with` expressions, such as:
Point shadowPos = shape.position() with { x = 0 }
The document evaluates a general mechanism involving matched pairs of
constructors (or factories) and deconstruction patterns, which is
still several steps out, but we can take this step now with records,
because records have all the characteristics (significant names,
canonical ctor and dtor) that are needed. The main reason we might
wait is if there are uncertainties in the broader target.
Our C# friends have already gone here, in a way that fits into C#,
using properties (which makes sense, as their language is built on that):
object with { property-assignments }
The C# interpretation is that the RHS of this expression is a sort of
"DSL", which permits property assignment but nothing else. This is
cute, but I think we can do better.
In our version, the RHS is an arbitrary block of Java code; you can
use loops, assignments, exceptions, etc. The only thing that makes it
"special" is that that the components of the operand are lifted into
mutable locals on the RHS. So inside the RHS when the operand is a
Point, there are fresh mutable locals `x` and `y` which are
initialized with the X and Y values of the operand. Their values are
committed at the end of the block using the canonical constructor.
This should remind people of the *compact constructor* in a record;
the body is allowed to freely mutate the special variables (who also
don't have obvious declarations), and their terminal values determine
the state of the record.
Just as we were able to do record patterns without having full-blown
deconstructors, we can do with expressions on records as well, because
(a) we still have a canonical ctor, (b) we have accessors, and (c) we
know the names of the components.
Obviously when we get value types, we'll want classes to be able to
expose (or not) such a mechanism (both for internal or external use).
#### Digression: builders
As a bonus, I think `with` offers us a better path to getting rid of
builders than the (problematic) one everyone asks for (default values
on constructor parameters.) Consider the case of a record with many
components, all of which are optional:
record Config(int a,
int b,
int c,
...
int z) {
}
Obviously, no one wants to call the canonical constructor with 26
values. The standard workaround is a builder, but that's a lot of
ceremony. The `with` mechanism gives us a way out:
record Config(int a,
int b,
int c,
...
int z) {
private Config() {
this(0, 0, 0, ... 0);
}
public static Config BUILDER = new Config();
}
Now we can just say
Config c = Config.BUILDER with { c = 3; q = 45; }
The constant isn't even necessary; we can just open up the
constructor. And if there are some required args, the constructor can
expose them too. Suppose a and b are required, but c..z are
optional. Then:
record Config(int a,
int b,
int c,
...
int z) {
public Config(int a, int b) {
this(a, b, 0, ... 0);
}
}
Config c = new Config(1, 2) with { c = 3; q = 45; }
In this way, the record acts as its own builder.
(As an added bonus, the default values do not suffer from the "brittle
constant" problem that a default value would likely suffer from,
because they are an implementation detail of the constructor, not an
exposed part of the API.)
I think it is reasonable at this point to take this idea off the shelf
and work towards delivering this for records, while we're building out
the machinery needed to deliver this for general classes. It has no
remaining dependencies and is immediately useful for records.
(As usual, please hold comments on small details until everyone has
had a chance to comment on the general direction.)