Record copy()/with()

Remi Forax forax at univ-mlv.fr
Sat May 23 16:07:21 UTC 2020


----- Mail original -----
> De: "Tagir Valeev" <amaembo at gmail.com>
> À: "Amber Expert Group Observers" <amber-spec-observers at openjdk.java.net>
> Cc: "amber-spec-experts" <amber-spec-experts at openjdk.java.net>
> Envoyé: Samedi 23 Mai 2020 17:30:45
> Objet: Re: Record copy()/with()

> Hello, Sergei!
> 
> I see a number of problems with your proposed approach.
> 
> 1. Naming. Java never specified any kind of derived identifiers using
> prefixes/suffixes/etc. And, in my opinion, it's for good, because such
> things make the spec dirty. You assume that every record component is
> named in plain English but Java allows any language in identifiers.
> The method name like with年齢 or withВозраст would not look right. Also,
> it would be necessary to specify how uppercasing is performed. Not
> every character valid in Java identifier has an uppercase equivalent.
> Also, what if the record component name is already uppercase? What if
> there are two record components that differ in the first letter case
> only? Should we prohibit such records? What if the record component
> name is a Turkish word that starts with lowercase 'i'? Should we apply
> tr locale for changing the case yielding grammatically correct dotted
> İ (but making the code generation locale-dependent) or should we use
> root locale always producing grammatically incorrect dot-less I?
> Unlike some other languages, Java is known for very strict spec, this
> is the strong part of Java. Having all of these corner cases specified
> makes me sad. Btw, such constructs add a pain for IDE developers as
> well. Imagine that a user starts a rename refactoring at the wither
> call.
> 
> 2. Assuming that record components could be validated in the
> constructor, not every record could be created using step-by-step
> builders. Imagine if you have a record Point(int x, int y) with a
> restriction that components must have an equal sign. You can create
> from the scratch new Point(1, 1) or new Point(-1, -1), but new
> Point(1, 1).withX(-1).withY(-1) would not work.
> 
> 3. Record components could also be normalized in the constructor. E.g.
> assume record Fraction(int numerator, int denominator) that normalizes
> the components using GCD in the constructor. Using withers would
> produce a weird result based on the previous value.

A record is transparent (same API externally and internally) so as a user i expect that if i set a record component to 3 using a constructor and then ask its value, the result will be 3,
so i don't think normalizing values of a record in the constructor is a good idea.
This issue is independent of with/copy, calling a constructor with the results of some accessors of an already constructed gcd will produce weird results. 

> 
> 4. Points 2 and 3 may lead to the conclusion that not every record
> actually needs copying. In fact, I believe, only a few of them would
> need it. Adding them automatically would pollute the API and people
> may accidentally use them. I believe, if any automatic copying
> mechanism will be added, it should be explicitly enabled for specific
> records.

with/copy calls the canonical constructor at the end, it's not something that provide a new behavior, but more a syntactic sugar you provide because updating few fields of a record declaring a dozen of components by calling the canonical constructor explicitly involve a lot of boilerplate code that may hide stupid bugs like the values of two components can be swapped because the code called the accessors in the wrong order.

> 
> With best regards,
> Tagir Valeev.

Rémi

> 
> On Sat, May 23, 2020 at 10:06 PM Sergei Egorov <bsideup at gmail.com> wrote:
>>
>> Hi Remi,
>>
>> Thanks for raising this important topic!
>>
>> I wonder if Records can provide the good old
>> withName()/withAge()/withWhatever() methods and the compiler will either
>> merge them or we let EA do the rest?
>>
>> On Sat, May 23, 2020 at 1:36 PM Remi Forax <forax at univ-mlv.fr> wrote:
>>
>> > I've spent a little time to see how we can provide a with()/copy() method,
>> > that creates a new instance from an existing record changing the value of
>> > several components in the process.
>> >
>> > It's a feature that was requested several times while i was presenting how
>> > record works and Scala, Kotlin and C# all provide an equivalent.
>> > And there is a way to add it without introducing a new syntax.
>> >
>> > Syntax in Scala or Kotlin:
>> >   val otherPerson = person.copy(name = "John", age = 17)
>> >
>> > Syntax in C#:
>> >   var otherPerson = person with { Name = "John", Age = 17 };
>> >
>> > One can note that the syntax in C# doesn't reuse the named argument syntax
>> > of C#,
>> > with the named arguments syntax, it should be
>> >   var otherPerson = person.copy(name: "John", age: 17);
>> >
>> >
>> > For Java, one solution is to re-use the same trick used to by
>> > MethodHandle.invoke*()/VarHandle.*, have a special syntax that is very
>> > close to the actual syntax so the feature is nicely integrated with the
>> > rest of the language. Here, the last time we discuss this, we stumble
>> > because unlike with MethodHandle.invoke*(), the arguments are a key/value
>> > pairs and there is no existing syntax for that currently in Java.
>> >
>> > I believe there possible trick here, use Object... as Map.of() does.
>> > the idea is to add a method 'Record with(Object... componentValuePairs)'
>> > in java.lang.Record, and ask the compiler to verify that the even arguments
>> > (0, 2, 4, etc) are constant strings
>> >
>> > Proposed syntax:
>> >  var otherPerson = person.with("name", "John", "age", 17);
>> >
>> > Then the compiler translates the method call to an invokedynamic to a
>> > bootstrap method
>> >   public static CallSite bsm(Lookup lookup, String name, MethodType type,
>> > String[] componentNames)
>> > the componentNames being "name" and "age" in the example. The methodType
>> > has the record as first parameter type and return type, the other
>> > parameters are the types of the record components corresponding to the
>> > component names.
>> >
>> > In term of separate compilation, the BSM should verify that the component
>> > names are valid record component and that the method type parameters has
>> > the same type as the corresponding record component.
>> > So adding a new record component is a compatible change, removing a record
>> > component or changing its type is not if a method call to 'with' reference
>> > it.
>> >
>> > In term of Class.getMethod()/Lookup.findVirtual(), I propose to not see
>> > the method 'with', so it's just a compiler artifact, if someone want the
>> > method 'with' at runtime, he can call the bootstrap method.
>> >
>> > regards,
>> > Rémi
>> >
>> > PS: here is the code for the bootstrap method
>> >
>> > ---
>> >   public static CallSite bsm(Lookup lookup, String name, MethodType type,
>> > String[] componentNames) {
>> >     Objects.requireNonNull(lookup);
>> >     Objects.requireNonNull(name);
>> >     if (type.parameterCount() == 0 || type.returnType() !=
>> > type.parameterType(0)) {
>> >       throw new IllegalArgumentException("invalid method type " + type);
>> >     }
>> >     if (componentNames.length != type.parameterCount() - 1) { // implicit
>> > null check
>> >       throw new IllegalArgumentException("wrong number of component names
>> > ");
>> >     }
>> >
>> >     HashMap<String, Integer> withIndexMap = new HashMap<>();
>> >     for (int i = 0; i < componentNames.length; i++) {
>> >       String componentName = Objects.requireNonNull(componentNames[i]);
>> >       Object result = withIndexMap.put(componentName, i + 1); // 'this' is
>> > at position 0
>> >       if (result != null) {
>> >         throw new IllegalArgumentException(
>> >             "component names contains twice the same name " +
>> > componentName);
>> >       }
>> >     }
>> >
>> >     Class<?> recordType = type.returnType();
>> >     RecordComponent[] components = recordType.getRecordComponents();
>> >     if (components == null) {
>> >       throw new IllegalArgumentException("the return type is not a record
>> > " + recordType.getName());
>> >     }
>> >
>> >     int length = components.length;
>> >     Class<?>[] constructorParameterTypes = new Class<?>[length];
>> >     int[] reorder = new int[length];
>> >     MethodHandle[] filters = new MethodHandle[length];
>> >     for (int i = 0; i < length; i++) {
>> >       RecordComponent component = components[i];
>> >       String componentName = component.getName();
>> >       Class<?> componentType = component.getType();
>> >       constructorParameterTypes[i] = componentType;
>> >       int withIndex = withIndexMap.getOrDefault(componentName, -1);
>> >       // a record component value either comes from the arguments or from
>> > this + accessor call
>> >       if (withIndex == -1) { // it comes from this + accessor
>> >         try {
>> >           filters[i] = lookup.unreflect(component.getAccessor());
>> >         } catch (IllegalAccessException e) {
>> >           throw (IllegalAccessError) new IllegalAccessError().initCause(e);
>> >         }
>> >         // and reorder[i] == 0
>> >       } else { // it comes from the arguments
>> >         reorder[i] = withIndex;
>> >         if (type.parameterType(withIndex) != componentType) {
>> >           throw new IncompatibleClassChangeError(
>> >               "invalid parameter type "
>> >                   + componentType
>> >                   + " at "
>> >                   + withIndex
>> >                   + " for component name "
>> >                   + componentName);
>> >         }
>> >         withIndexMap.remove(componentName); // mark that the component
>> > name has been visited
>> >         // and filter[i] == null
>> >       }
>> >     }
>> >
>> >     if (!withIndexMap.isEmpty()) { // some component names do not exist
>> >       throw new IncompatibleClassChangeError("invalid component names " +
>> > withIndexMap.keySet());
>> >     }
>> >
>> >     MethodHandle constructor;
>> >     try {
>> >       constructor = lookup.findConstructor(recordType,
>> > MethodType.methodType(void.class, constructorParameterTypes));
>> >     } catch (NoSuchMethodException e) {
>> >       throw (NoSuchMethodError) new NoSuchMethodError().initCause(e);
>> >     } catch (IllegalAccessException e) {
>> >       throw (IllegalAccessError) new IllegalAccessError().initCause(e);
>> >     }
>> >     MethodHandle filtered = MethodHandles.filterArguments(constructor, 0,
>> > filters);
>> >     MethodHandle target = MethodHandles.permuteArguments(filtered, type,
>> > reorder);
>> >     return new ConstantCallSite(target);
>> >   }
>> >
>> >
>> >
>> >
>> >
>> >
>> >
>> >


More information about the amber-spec-observers mailing list