Skip to content

Instantly share code, notes, and snippets.

@NetzwergX
Created June 7, 2020 15:15
Show Gist options
  • Save NetzwergX/4ebba8ea36d0663f2a540d0f71f16e49 to your computer and use it in GitHub Desktop.
Save NetzwergX/4ebba8ea36d0663f2a540d0f71f16e49 to your computer and use it in GitHub Desktop.
CopyableRecord - Impl & Tests
/*
* 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);
}*/
}
}
/*
* 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