Forked from NetzwergX/lava.util.CopyableRecord.java
Created
January 17, 2022 18:00
-
-
Save s4gh/8ee44ac4017ba8527de6139c1df2eb7f to your computer and use it in GitHub Desktop.
CopyableRecord - Impl & Tests
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* MIT License | |
* | |
* Copyright (c) 2020 Sebastian Teumert <https://sebastian.teumert.net> | |
* | |
* Permission is hereby granted, free of charge, to any person obtaining a copy | |
* of this software and associated documentation files (the "Software"), to deal | |
* in the Software without restriction, including without limitation the rights | |
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
* copies of the Software, and to permit persons to whom the Software is | |
* furnished to do so, subject to the following conditions: | |
* | |
* The above copyright notice and this permission notice shall be included in all | |
* copies or substantial portions of the Software. | |
* | |
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
* SOFTWARE. | |
*/ | |
package lava.util; | |
import java.io.Serializable; | |
import java.lang.invoke.SerializedLambda; | |
import java.lang.invoke.VarHandle; | |
import java.lang.reflect.InvocationTargetException; | |
import java.lang.reflect.Method; | |
import java.lang.reflect.RecordComponent; | |
import java.util.NoSuchElementException; | |
import java.util.function.Function; | |
/** | |
* <p>Offers transformation capabilities for records, transforming an (immutable) | |
* record fluidly into a new record instance in a typesafe way.</p> | |
* | |
* <b>Example:</b> | |
* <pre> | |
* {@code new Person("James", "Gosling", 0).with(Person::age, 65)); | |
* // Person[firstName=James, lastName=Gosling, age=65]} | |
* </pre> | |
* | |
* @author Sebastian Teumert | |
* | |
* @param <R> the implementing record itself | |
*/ | |
public interface CopyableRecord<R extends Record & CopyableRecord<R>> { | |
private static boolean isCompatible(RecordComponent component, String name, | |
String typeDescriptor) { | |
return component.getName().equals(name) && component.getAccessor() | |
.getReturnType().descriptorString().equals(typeDescriptor); | |
} | |
/** | |
* 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); | |
} | |
/** | |
* c.f. https://stackoverflow.com/a/35223119/1360803 | |
*/ | |
@Deprecated | |
@SuppressWarnings({ "rawtypes", "unused" }) | |
private static Method getReflectedMethod(Serializable lambda) | |
throws NoSuchMethodException, SecurityException, | |
IllegalAccessException, IllegalArgumentException, | |
InvocationTargetException, ClassNotFoundException { | |
SerializedLambda s = getSerializedLambda(lambda); | |
Class containingClass = Class.forName(s.getImplClass()); | |
String methodName = s.getImplMethodName(); | |
for (Method m : containingClass.getDeclaredMethods()) { | |
if (m.getName().equals(methodName)) | |
return m; | |
} | |
throw new NoSuchElementException("reflected method could not be found"); | |
} | |
/** | |
* <p> | |
* Create a copy of this record, replacing exactly one field with another | |
* value. Can be chained to change multiple fields. | |
* </p> | |
* | |
* <p> | |
* The given method reference must be the accessor method for the field, the | |
* given value will replace the old value of that field in the copy. | |
* </p> | |
* | |
* <p> | |
* <b>Example:</b> | |
* | |
* <pre> | |
* {@code new Person("James", "Gosling", 0).with(Person::age, 65)); | |
* // Person[firstName=James, lastName=Gosling, age=65]} | |
* </pre> | |
* </p> | |
* | |
* @param <T> Type of the field to replace | |
* @param <F> Type of the accessor method | |
* @param param Accessor method of the field to replace | |
* @param val Value the field should have in the copy | |
* @return copy of the record, with the field replaced with the given value | |
*/ | |
@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); | |
}*/ | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* MIT License | |
* | |
* Copyright (c) 2020 Sebastian Teumert <https://sebastian.teumert.net> | |
* | |
* Permission is hereby granted, free of charge, to any person obtaining a copy | |
* of this software and associated documentation files (the "Software"), to deal | |
* in the Software without restriction, including without limitation the rights | |
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
* copies of the Software, and to permit persons to whom the Software is | |
* furnished to do so, subject to the following conditions: | |
* | |
* The above copyright notice and this permission notice shall be included in all | |
* copies or substantial portions of the Software. | |
* | |
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
* SOFTWARE. | |
*/ | |
package lava.util; | |
import static org.junit.jupiter.api.Assertions.*; | |
import java.util.Arrays; | |
import org.junit.jupiter.api.Test; | |
/** | |
* @author Netzwerg | |
* | |
*/ | |
class CopyableRecordTest { | |
public static record WithObject(String name) | |
implements CopyableRecord<WithObject> { }; | |
public static record WithPrimitive(int primitive) | |
implements CopyableRecord<WithPrimitive> { }; | |
public static record WithArray(int[] array) | |
implements CopyableRecord<WithArray> {}; | |
public static record WithGeneric<T>(T generic) | |
implements CopyableRecord<WithGeneric<T>> {}; | |
public static record WithGenericArray<T>(T[] generic) | |
implements CopyableRecord<WithGenericArray<T>> {}; | |
public static record WithAll<T>(String name, int primitive, | |
int[] array, T generic, T[] genericArray) | |
implements CopyableRecord<WithAll<T>> { }; | |
public static record Doubling (int n, int m) implements CopyableRecord<Doubling> { | |
public int n() { | |
return 2 * n; | |
} | |
public int m() { | |
return 2 * m; | |
} | |
} | |
@Test | |
void testTransformObject() { | |
var guruJava = new WithObject("Brian Goetz"); | |
var guruCSharp = guruJava.with(WithObject::name, "Eric Lippert"); | |
assertNotEquals(guruJava, guruCSharp); | |
assertEquals(guruJava, new WithObject("Brian Goetz")); // stayed same | |
assertEquals( guruCSharp, new WithObject("Eric Lippert")); // is changed | |
} | |
@Test | |
void testTransformPrimitive() { | |
var original = new WithPrimitive(5); | |
var copy = original.with(WithPrimitive::primitive, 10); | |
assertNotEquals(original, copy); | |
assertEquals(original, new WithPrimitive(5)); // stayed same | |
assertEquals(copy, new WithPrimitive(10)); // is changed | |
} | |
@Test | |
void testTransformArray() { | |
var original = new WithArray(new int[] {1,2,3}); | |
var copy = original.with(WithArray::array, new int[] {3, 4, 5}); | |
assertNotEquals(original, copy); | |
assertTrue(Arrays.equals(original.array, new int[] {1,2,3})); | |
assertTrue(Arrays.equals(copy.array, new int[] {3,4,5})); | |
} | |
@Test | |
void testTransformGeneric() { | |
var original = new WithGeneric<String>("Original"); | |
var copy = original.with(WithGeneric<String>::generic, "Copy"); | |
assertNotEquals(original, copy); | |
assertEquals(original, new WithGeneric<String>("Original")); | |
assertEquals(copy, new WithGeneric<String>("Copy")); | |
} | |
@Test | |
void testTransformGenericArray() { | |
var original = new WithGenericArray<String>(new String[] {"A", "B", "C"}); | |
var copy = original.with(WithGenericArray<String>::generic, new String[] {"C", "D", "E"}); | |
assertNotEquals(original, copy); | |
assertTrue(Arrays.equals(original.generic, new String[] {"A", "B", "C"})); | |
assertTrue(Arrays.equals(copy.generic, new String[] {"C", "D", "E"})); | |
} | |
// this test fails on purpose, it highlights one serious flaw with using | |
// accessors | |
@Test | |
void testTransformDoubling() { | |
var original = new Doubling(2, 3); | |
var copy = original.with(Doubling::n, 5); | |
assertEquals(4, original.n()); | |
assertEquals(6, original.m()); | |
assertEquals(10, copy.n()); | |
assertEquals(6, copy.m()); | |
assertNotEquals(12, copy.m()); | |
} | |
// even with fields, the next record would still "break" on copy: | |
public static record DoublingConstructor (int n, int m) implements CopyableRecord<Doubling> { | |
public DoublingConstructor { | |
n = 2 * n; | |
m = 2 * m; | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment