Record copy()/with()

Remi Forax forax at univ-mlv.fr
Sat May 23 11:35:42 UTC 2020


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-experts mailing list