Skip to content

Commit

Permalink
Optics based on kotlin.reflect (#2612)
Browse files Browse the repository at this point in the history
Co-authored-by: serras <serras@users.noreply.github.com>
  • Loading branch information
serras and serras authored Feb 9, 2022
1 parent 5056131 commit 16d8136
Show file tree
Hide file tree
Showing 13 changed files with 261 additions and 0 deletions.
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;
}

31 changes: 31 additions & 0 deletions arrow-libs/optics/arrow-optics-reflect/build.gradle.kts
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)
}
}
}
}
4 changes: 4 additions & 0 deletions arrow-libs/optics/arrow-optics-reflect/gradle.properties
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
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
}
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
}
}
}
4 changes: 4 additions & 0 deletions arrow-libs/optics/arrow-optics/api/arrow-optics.api
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public abstract interface class arrow/optics/Fold {
public abstract fun getAll (Ljava/lang/Object;)Ljava/util/List;
public abstract fun isEmpty (Ljava/lang/Object;)Z
public abstract fun isNotEmpty (Ljava/lang/Object;)Z
public static fun iterable ()Larrow/optics/Fold;
public abstract fun lastOrNull (Ljava/lang/Object;)Ljava/lang/Object;
public abstract fun left ()Larrow/optics/Fold;
public static fun list ()Larrow/optics/Fold;
Expand All @@ -40,6 +41,7 @@ public final class arrow/optics/Fold$Companion {
public final fun codiagonal ()Larrow/optics/Fold;
public final fun either ()Larrow/optics/Fold;
public final fun id ()Larrow/optics/Fold;
public final fun iterable ()Larrow/optics/Fold;
public final fun list ()Larrow/optics/Fold;
public final fun map ()Larrow/optics/Fold;
public final fun nonEmptyList ()Larrow/optics/Fold;
Expand Down Expand Up @@ -464,6 +466,7 @@ public abstract interface class arrow/optics/POptional : arrow/optics/PEvery, ar
public static fun listTail ()Larrow/optics/POptional;
public abstract fun modify (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object;
public abstract fun modifyNullable (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object;
public static fun nullable ()Larrow/optics/POptional;
public abstract fun plus (Larrow/optics/POptional;)Larrow/optics/POptional;
public abstract fun second ()Larrow/optics/POptional;
public abstract fun set (Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;
Expand All @@ -476,6 +479,7 @@ public final class arrow/optics/POptional$Companion {
public final fun invoke (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)Larrow/optics/POptional;
public final fun listHead ()Larrow/optics/POptional;
public final fun listTail ()Larrow/optics/POptional;
public final fun nullable ()Larrow/optics/POptional;
public final fun void ()Larrow/optics/POptional;
}

Expand Down
2 changes: 2 additions & 0 deletions arrow-libs/optics/arrow-optics/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,14 @@ kotlin {
jvmMain {
dependencies {
implementation(libs.kotlin.stdlibJDK8)
api(libs.kotlin.reflect)
}
}
jvmTest {
dependencies {
implementation(libs.kotlin.stdlibJDK8)
implementation(libs.junitJupiterEngine)
implementation(libs.kotlin.reflect)
}
}
jsMain {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import arrow.core.Tuple6
import arrow.core.Tuple7
import arrow.core.Tuple8
import arrow.core.Tuple9
import arrow.core.foldMap
import arrow.core.identity
import arrow.typeclasses.Monoid
import kotlin.jvm.JvmStatic
Expand Down Expand Up @@ -199,6 +200,13 @@ public interface Fold<S, A> {
public fun <A, B> void(): Fold<A, B> =
POptional.void()

@JvmStatic
public fun <A> iterable(): Fold<Iterable<A>, A> =
object : Fold<Iterable<A>, A> {
override fun <R> foldMap(M: Monoid<R>, source: Iterable<A>, map: (focus: A) -> R): R =
source.foldMap(M, map)
}

/**
* [Traversal] for [List] that focuses in each [A] of the source [List].
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import arrow.core.Some
import arrow.core.flatMap
import arrow.core.identity
import arrow.core.prependTo
import arrow.core.toOption
import arrow.typeclasses.Monoid
import kotlin.jvm.JvmStatic

Expand Down Expand Up @@ -198,5 +199,11 @@ public interface POptional<S, T, A, B> : PSetter<S, T, A, B>, POptionalGetter<S,
getOption = { if (it.isEmpty()) None else Some(it.drop(1)) },
set = { list, newTail -> if (list.isNotEmpty()) list[0] prependTo newTail else emptyList() }
)

@JvmStatic
public fun <A> nullable(): Optional<A?, A> = Optional(
getOption = { it.toOption() },
set = { source, new -> source?.let { new } }
)
}
}
3 changes: 3 additions & 0 deletions arrow-site/docs/_data/sidebar-optics.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ options:
- title: Setter
url: /optics/setter/

- title: Reflection
url: /optics/reflection/

- title: Collections DSL

nested_options:
Expand Down
2 changes: 2 additions & 0 deletions arrow-site/docs/docs/optics/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ Scroll down and learn what Arrow Optics can do for you(r code)!
- [Getter]({{ '/optics/getter/' | relative_url }}): focus on one value
- [OptionalGetter]({{ '/optics/optional_getter/' | relative_url }}): focus on optional value
- [Setter]({{ '/optics/setter/' | relative_url }}): modify one value

[Usage with reflection]({{ '/optics/reflection/' | relative_url }})

</div>

Expand Down
68 changes: 68 additions & 0 deletions arrow-site/docs/docs/optics/reflection/README.md
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)
```
3 changes: 3 additions & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ project(":arrow-fx-stm").projectDir = file("arrow-libs/fx/arrow-fx-stm")
include("arrow-optics")
project(":arrow-optics").projectDir = file("arrow-libs/optics/arrow-optics")

include("arrow-optics-reflect")
project(":arrow-optics-reflect").projectDir = file("arrow-libs/optics/arrow-optics-reflect")

include("arrow-optics-ksp-plugin")
project(":arrow-optics-ksp-plugin").projectDir = file("arrow-libs/optics/arrow-optics-ksp-plugin")

Expand Down

0 comments on commit 16d8136

Please sign in to comment.