-
Notifications
You must be signed in to change notification settings - Fork 448
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Optics based on kotlin.reflect (#2612)
Co-authored-by: serras <serras@users.noreply.github.com>
- Loading branch information
Showing
13 changed files
with
261 additions
and
0 deletions.
There are no files selected for viewing
10 changes: 10 additions & 0 deletions
10
arrow-libs/optics/arrow-optics-reflect/api/arrow-optics-reflect.api
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
public final class arrow/optics/ReflectionKt { | ||
public static final fun getEvery (Lkotlin/reflect/KProperty1;)Larrow/optics/PEvery; | ||
public static final fun getIter (Lkotlin/jvm/functions/Function1;)Larrow/optics/Fold; | ||
public static final fun getLens (Lkotlin/reflect/KProperty1;)Larrow/optics/PLens; | ||
public static final fun getOgetter (Lkotlin/jvm/functions/Function1;)Larrow/optics/Getter; | ||
public static final fun getOptional (Lkotlin/reflect/KProperty1;)Larrow/optics/POptional; | ||
public static final fun getValues (Lkotlin/reflect/KProperty1;)Larrow/optics/PEvery; | ||
public static final fun instance (Lkotlin/reflect/KClass;)Larrow/optics/PPrism; | ||
} | ||
|
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
plugins { | ||
id(libs.plugins.kotlin.multiplatform.get().pluginId) | ||
alias(libs.plugins.arrowGradleConfig.kotlin) | ||
alias(libs.plugins.arrowGradleConfig.publish) | ||
} | ||
|
||
apply(plugin = "io.kotest.multiplatform") | ||
|
||
apply(from = property("TEST_COVERAGE")) | ||
apply(from = property("ANIMALSNIFFER_MPP")) | ||
|
||
kotlin { | ||
sourceSets { | ||
jvmMain { | ||
dependencies { | ||
api(projects.arrowCore) | ||
api(projects.arrowOptics) | ||
implementation(libs.kotlin.stdlibJDK8) | ||
api(libs.kotlin.reflect) | ||
} | ||
} | ||
jvmTest { | ||
dependencies { | ||
implementation(projects.arrowOpticsTest) | ||
implementation(libs.kotlin.stdlibJDK8) | ||
implementation(libs.junitJupiterEngine) | ||
implementation(libs.kotlin.reflect) | ||
} | ||
} | ||
} | ||
} |
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
# Maven publishing configuration | ||
pom.name=Arrow Optics for Kotlin Reflection | ||
# Build configuration | ||
kapt.incremental.apt=false |
64 changes: 64 additions & 0 deletions
64
arrow-libs/optics/arrow-optics-reflect/src/jvmMain/kotlin/arrow/optics/Reflection.kt
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
package arrow.optics | ||
|
||
import arrow.core.Either | ||
import arrow.core.left | ||
import arrow.core.right | ||
import kotlin.reflect.* | ||
import kotlin.reflect.full.instanceParameter | ||
import kotlin.reflect.full.memberFunctions | ||
|
||
/** Focuses on those elements of the specified [klass] */ | ||
public fun <S: Any, A: S> instance(klass: KClass<A>): Prism<S, A> = | ||
object: Prism<S, A> { | ||
override fun getOrModify(source: S): Either<S, A> = | ||
klass.safeCast(source)?.right() ?: source.left() | ||
override fun reverseGet(focus: A): S = focus | ||
} | ||
|
||
/** Focuses on those elements of the specified class */ | ||
public inline fun <S: Any, reified A: S> instance(): Prism<S, A> = | ||
object: Prism<S, A> { | ||
override fun getOrModify(source: S): Either<S, A> = | ||
(source as? A)?.right() ?: source.left() | ||
override fun reverseGet(focus: A): S = focus | ||
} | ||
|
||
/** Focuses on a given field */ | ||
public val <S, A> ((S) -> A).ogetter: Getter<S, A> | ||
get() = Getter { s -> this(s) } | ||
|
||
/** | ||
* [Lens] that focuses on a field in a data class | ||
* | ||
* WARNING: this should only be called on data classes, | ||
* but that is checked only at runtime! | ||
*/ | ||
public val <S, A> KProperty1<S, A>.lens: Lens<S, A> | ||
get() = PLens( | ||
get = this, | ||
set = { s, a -> clone(this, s, a) } | ||
) | ||
|
||
/** [Optional] that focuses on a nullable field */ | ||
public val <S, A> KProperty1<S, A?>.optional: Optional<S, A> | ||
get() = lens compose Optional.nullable() | ||
|
||
public val <S, A> ((S) -> Iterable<A>).iter: Fold<S, A> | ||
get() = ogetter compose Fold.iterable() | ||
|
||
public val <S, A> KProperty1<S, List<A>>.every: Every<S, A> | ||
get() = lens compose Every.list() | ||
|
||
public val <S, K, A> KProperty1<S, Map<K, A>>.values: Every<S, A> | ||
get() = lens compose Every.map() | ||
|
||
private fun <S, A> clone(prop: KProperty1<S, A>, value: S, newField: A): S { | ||
// based on https://stackoverflow.com/questions/49511098/call-data-class-copy-via-reflection | ||
val klass = prop.instanceParameter?.type?.classifier as? KClass<*> | ||
val copy = klass?.memberFunctions?.firstOrNull { it.name == "copy" } | ||
if (klass == null || !klass.isData || copy == null) { | ||
throw IllegalArgumentException("may only be used with data classes") | ||
} | ||
val fieldParam = copy.parameters.first { it.name == prop.name } | ||
return copy.callBy(mapOf(copy.instanceParameter!! to value, fieldParam to newField)) as S | ||
} |
55 changes: 55 additions & 0 deletions
55
arrow-libs/optics/arrow-optics-reflect/src/jvmTest/kotlin/arrow/optics/ReflectionTest.kt
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,55 @@ | ||
package arrow.optics | ||
|
||
import arrow.core.test.UnitSpec | ||
import io.kotest.matchers.shouldBe | ||
import io.kotest.property.Arb | ||
import io.kotest.property.arbitrary.int | ||
import io.kotest.property.arbitrary.string | ||
|
||
data class Person(val name: String, val friends: List<String>) | ||
|
||
sealed interface Cutlery | ||
object Fork: Cutlery | ||
object Spoon: Cutlery | ||
|
||
object ReflectionTest: UnitSpec() { | ||
init { | ||
"optional for function" { | ||
checkAll(Arb.list(Arb.int())) { ints -> | ||
val firsty = { it: List<Int> -> it.firstOrNull() } | ||
firsty.ogetter.get(ints) shouldBe ints.firstOrNull() | ||
} | ||
} | ||
|
||
"lenses for field, get" { | ||
checkAll(Arb.string(), Arb.list(Arb.string())) { nm, fs -> | ||
val p = Person(nm, fs.toMutableList()) | ||
Person::name.lens.get(p) shouldBe nm | ||
} | ||
} | ||
|
||
"lenses for field, set" { | ||
checkAll(Arb.string(), Arb.list(Arb.string())) { nm, fs -> | ||
val p = Person(nm, fs.toMutableList()) | ||
val m = Person::name.lens.modify(p) { it.capitalize() } | ||
m shouldBe Person(nm.capitalize(), fs) | ||
} | ||
} | ||
|
||
"traversal for list, set" { | ||
checkAll(Arb.string(), Arb.list(Arb.string())) { nm, fs -> | ||
val p = Person(nm, fs) | ||
val m = Person::friends.every.modify(p) { it.capitalize() } | ||
m shouldBe Person(nm, fs.map { it.capitalize() }) | ||
} | ||
} | ||
|
||
"instances" { | ||
val things = listOf(Fork, Spoon, Fork) | ||
val forks = Every.list<Cutlery>() compose instance<Cutlery, Fork>() | ||
val spoons = Every.list<Cutlery>() compose instance<Cutlery, Spoon>() | ||
forks.size(things) shouldBe 2 | ||
spoons.size(things) shouldBe 1 | ||
} | ||
} | ||
} |
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
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
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
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
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
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
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,68 @@ | ||
--- | ||
layout: docs-optics | ||
title: Index | ||
permalink: /optics/reflection/ | ||
--- | ||
|
||
## Usage with reflection | ||
|
||
Although we strongly recommend generating optics using the [DSL and `@optics` attribute]({{ '/optics/dsl/' | relative_url }}), sometimes this is not possible. For those scenarios we provide a small utility package `arrow-optics-reflect` which bridges Arrow Optics with [Kotlin's reflection](https://kotlinlang.org/docs/reflection.html) capabilities. | ||
|
||
Kotlin provides a simple way to obtain a reference to a member of a class, by using `ClassName::memberName`. For example, given the following class definition: | ||
|
||
```kotlin | ||
data class Person(val name: String, val friends: List<String>) | ||
``` | ||
|
||
we can use `Person::name` and `Person::friends` to refer to each of the fields in the class. Those references are very similar to optics. | ||
|
||
In fact, what `arrow-optics-reflect` does is provide extension methods which turn those references into optics. You can obtain a lens for the `name` field in `Person` by writing: | ||
|
||
```kotlin | ||
Person::name.lens | ||
``` | ||
|
||
which you can later use as [any other lens]({{ '/optics/lens' | relative_url }}): | ||
|
||
```kotlin | ||
val p = Person("me", listOf("pat", "mat")) | ||
val m = Person::name.lens.modify(p) { it.capitalize() } | ||
``` | ||
|
||
⚠️ **WARNING**: this only works on `data` classes with a public `copy` method (which is the default.) Remember that, as opposed to a mutable variable, optics will always create a _new_ copy when asking for modification. | ||
|
||
### Nullables and collections | ||
|
||
Sometimes it's preferable to expose a field using a different optic: | ||
|
||
- When the type of the field is nullable, you can use `optional` to obtain an [optional]({{ '/optics/optional' | relative_url }}) instead of a lens. | ||
- When the type of the field is a collection, you can use `iter` to obtain _read-only_ access to it (technically, you obtain a [fold]({{ '/optics/fold' | relative_url }}).) If the type is a subclass of `List`, you can use `every` to get read/write access. | ||
|
||
```kotlin | ||
val p = Person("me", listOf("pat", "mat")) | ||
val m = Person::friends.every.modify(p) { it.capitalize() } | ||
``` | ||
|
||
### Prisms | ||
|
||
A common pattern in Kotlin programming is to define a sealed abstract class (or interface) with subclasses representing choices in a union. | ||
|
||
```kotlin | ||
sealed interface Cutlery | ||
object Fork: Cutlery | ||
object Spoon: Cutlery | ||
``` | ||
|
||
We provide an `instance` method which creates a [prism]({{ '/optics/prism' | relative_url }}) which focus only on a certain subclass of a parent class. Both ends are important and must be provided when creating the optic: | ||
|
||
```kotlin | ||
instance<Cutlery, Fork>() | ||
``` | ||
|
||
You can compose this optic freely with others. Here's an example in which we obtain the number of forks in a list of cutlery using optics: | ||
|
||
```kotlin | ||
val things = listOf(Fork, Spoon, Fork) | ||
val forks = Every.list<Cutlery>() compose instance<Cutlery, Fork>() | ||
val noOfForks = forks.size(things) | ||
``` |
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