raphw / byte-buddy

Runtime code generation for the Java virtual machine.
https://bytebuddy.net
Apache License 2.0
6.23k stars 804 forks source link

"Set value cannot be assigned" when setting lambda to static field in type initializer #1661

Closed LarsBodewig closed 3 months ago

LarsBodewig commented 3 months ago

I try to implement a byte-buddy plugin to introduce a static field with a functional interface type and an initial value into some library classes like this:

public class MyClass { }

should be transformed to

// this is just a functional interface for java.io.ObjectOutputStream::defaultWriteObject
public interface WriteInterface {
  void apply(java.io.ObjectOutputStream out) throws java.io.IOException;
}

public class MyClass {
  public static WriteInterface _writeObject = ObjectOutputStream::defaultWriteObject;
                     // or if that is easier: (out) -> out.defaultWriteObject()
}

I try to transform the classes at build-time, not using the Agent at runtime. I already found out, that I cannot use a LoadedTypeInitializer as they are not persisted in the bytecode and that Implementation.Composable.setsValue() can only work with constant values for the same reason. I found InvokeDynamic.lambda() that sounds like what I need.

So I tried implementing my plugin like this:

public interface WriteInterface {
  void apply(java.io.ObjectOutputStream out) throws java.io.IOException;
}

public class MyPlugin implements net.bytebuddy.Plugin {

  @Override
  public net.bytebuddy.dynamic.DynamicType.Builder<?> apply(
        net.bytebuddy.dynamic.DynamicType.Builder<?> builder,
        net.bytebuddy.description.type.TypeDescription typeDescription, 
        net.bytebuddy.dynamic.ClassFileLocator classFileLocator) {
    // add the field, works as expected
    builder = builder.defineField(
        "_writeObject", 
        WriteInterface.class, 
        net.bytebuddy.description.modifier.Visiblity.PUBLIC, 
        net.bytebuddy.description.modifier.Ownership.STATIC);

    // does not work as excepted
    builder = builder
        .invokable(net.bytebuddy.matcher.ElementMatchers.isTypeInitializer())
        .intercept(
            net.bytebuddy.implementation.FieldAccessor.ofField("_writeObject").setsValue(
                net.bytebuddy.implementation.InvokeDynamic.lambda(
                    java.io.ObjectOutputStream.class.getMethod("defaultWriteObject"),
                    WriteInterface.class)
                .withArgument(0))); // not sure if necessary, does not change the result atm

    return builder;
  }
  // close and matches excluded
}

But when I try to use my plugin the transformation fails with the following exception:

java.lang.IllegalStateException: Failed to transform class MyClass: [java.lang.IllegalStateException: Set value cannot be assigned to public static WriteInterface MyClass._writeObject]
    at net.bytebuddy.build.Plugin$Engine$ErrorHandler$Failing$1.onError (Plugin.java:1186)
    at net.bytebuddy.build.Plugin$Engine$ErrorHandler$Compound.onError (Plugin.java:1438)
    at net.bytebuddy.build.Plugin$Engine$Listener$ForErrorHandler.onError (Plugin.java:2022)
    at net.bytebuddy.build.Plugin$Engine$Listener$Compound.onError (Plugin.java:2151)
    at net.bytebuddy.build.Plugin$Engine$Default$Preprocessor$Resolved.call (Plugin.java:4904)
    at net.bytebuddy.build.Plugin$Engine$Default$Preprocessor$Resolved.call (Plugin.java:4850)
    at net.bytebuddy.build.Plugin$Engine$Dispatcher$ForSerialTransformation.accept (Plugin.java:3902)
    at net.bytebuddy.build.Plugin$Engine$Default.apply (Plugin.java:4697)
    at net.bytebuddy.build.maven.ByteBuddyMojo.apply (ByteBuddyMojo.java:469)
    at net.bytebuddy.build.maven.ByteBuddyMojo.execute (ByteBuddyMojo.java:340)

As far as I can tell, byte-buddy does create another field public static net.bytebuddy.implementation.InvokeDynamic$WithImplicitType$OfArgument MyClass.fixedFieldValue$... (I read somewhere that this is by design, so far so good) which resolves to a StackManipulation$Illegal in ReferenceTypeAwareAssigner.assign() because the source-type is not assignable to WriteInterface.

This feels like a bug, but maybe I am just using it the wrong way. So my question is:

How can I set an initial value for a functional interface type on a static field at build-time?

I also tried including the functional interface as DynamicType, but got the same result.

  // in MyPlugin.apply
 net.bytebuddy.dynamic.DynamicType writeInterface = new net.bytebuddy.ByteBuddy()
        .makeInterface()
        .name("WriteInterface")
        .defineMethod("apply", void.class, net.bytebuddy.description.modifier.Visibility.PUBLIC)
        .withParameter(java.io.ObjectOutputStream.class, "out")
        .throwing(java.io.IOException.class)
        .withoutCode()
        .make();

  builder = builder.require(writeInterface);

  builder = builder.defineField(
        "_writeObject", 
        writeInterface.getTypeDescription(),
        net.bytebuddy.description.modifier.Visiblity.PUBLIC, 
        net.bytebuddy.description.modifier.Ownership.STATIC);

  builder = builder
        .invokable(net.bytebuddy.matcher.ElementMatchers.isTypeInitializer())
        .intercept(
            net.bytebuddy.implementation.FieldAccessor.ofField("_writeObject").setsValue(
                net.bytebuddy.implementation.InvokeDynamic.lambda(
                    new net.bytebuddy.description.method.MethodDescription.ForLoadedMethod(
                        java.io.ObjectOutputStream.class.getMethod("defaultWriteObject"),
                    writeInterface.getTypeDescription())
                .withArgument(0)));

Any hints are greatly appreciated!

raphw commented 3 months ago

A lambda expression simply binds a value that is returned from a bootstrap method. If you want to bind a value, you should either intercept the constructor (members) or the initializer (static). From there, construct an expression and bind the result to the field. You could also use MethodDelegation or Advice to implement this code.

LarsBodewig commented 3 months ago

Thanks for taking the time!

As far as I understand I do intercept the initializer and create an expression by using FieldAccessor.ofField("_writeObject").setsValue(). If I change my added field to type String and use setsValue("myString") the field will be correctly initialized. I only struggle to represent the method reference as the value.

I am not sure if MethodDelegation could help, as I don't try to invoke ObjectOutputStream#defaultWriteObject during initialization but assign the method reference instead. The docs also state

For invoking a method on another instance, use the MethodCall implementation

so I tried to assign a MethodCall as value

// in MyPlugin.apply
builder = builder
        .invokable(net.bytebuddy.matcher.ElementMatchers.isTypeInitializer())
        .intercept(
            net.bytebuddy.implementation.FieldAccessor.ofField("_writeObject").setsValue(
                net.bytebuddy.implementation.MethodCall.of(
                    new net.bytebuddy.description.method.MethodDescription.ForLoadedMethod(
                        java.io.ObjectOutputStream.class.getMethod("defaultWriteObject"))
                .withArgument(0)));

but it fails with the same exception as before.

raphw commented 3 months ago

You will have to use InvokeDynamic#lambda to bind a lambda expression (or method reference). Unfortunately, there is not yet a way to set the lambda expression as a field.

Instead, you can construct the lambda expression manually using MethodInvocation by defining a manual call to LambdaMetaFactory.

When using MethodCall, you would also need to use the setsField method from there.

LarsBodewig commented 3 months ago

For future reference, if anyone has the same issue:

I tried to manually call the LambdaMetaFactory which should in theory return a MethodHandle that can be written to the byte code as a constant but ultimately got stuck on constructing the lambda correctly.

builder = builder.invokable(isTypeInitializer()).intercept(
                            FieldAccessor.ofField("_writeObject")
                                    .setsValue(
                                            // does not work :(
                                            LambdaMetafactory.metafactory(
                                                    MethodHandles.lookup(),
                                                    "apply",
                                                    MethodType.methodType(WriteInterface.class, ObjectOutputStream.class),
                                                    MethodType.methodType(void.class, ObjectOutputStream.class),
                                                    MethodHandles.lookup().findVirtual(ObjectOutputStream.class, "defaultWriteObject", MethodType.methodType(void.class)),
                                                    MethodType.methodType(void.class)
                                            ).getTarget()));

I ended up creating another dynamic type to implement the functional interface and delegate the call to the target method (the "pre-java-8-way"). Then I call its default constructor to set my static field. This does not solve the original question/error but is sufficient in my use case.

// in MyPlugin.apply
DynamicType writeInterface = new net.bytebuddy.ByteBuddy()
                    .makeInterface()
                    .name("WriteInterface")
                    .defineMethod("apply", void.class, Visibility.PUBLIC)
                    .withParameter(ObjectOutputStream.class, "out")
                    .throwing(IOException.class)
                    .withoutCode()
                    .make();

DynamicType writeDefaultImpl = new ByteBuddy()
                    .subclass(Object.class)
                    .name("DefaultImpl")
                    .implement(writeInterface.getTypeDescription())
                    .defineMethod("apply", void.class, Visibility.PUBLIC)
                    .withParameter(ObjectOutputStream.class, "out")
                    .throwing(IOException.class)
                    .intercept(MethodCall.invoke(new MethodDescription.ForLoadedMethod(
                            ObjectOutputStream.class.getMethod("defaultWriteObject"))).onArgument(0))
                    .make();

builder = builder.require(writeInterface);
builder = builder.require(writeDefaultImpl);

builder = builder.defineField(
          "_writeObject", 
          writeInterface.getTypeDescription(),
          Visiblity.PUBLIC, 
          Ownership.STATIC);

builder = builder.invokable(isTypeInitializer()).intercept(
                    MethodCall.construct(writeDefaultImpl.getTypeDescription().getDeclaredMethods().filter(isDefaultConstructor()).getOnly())
                           .setsField(named("_writeObject")));