Arrow: Idea for propery-based syntax for lenses

Created on 4 Feb 2018  路  12Comments  路  Source: arrow-kt/arrow

This approach allows threat lenses as extension properties.

Consider following code:

/** Lens with bound receiver */
interface BoundLens<T, FT> {
    fun get(): FT
    fun set(fv: FT): T
}

inline fun <T, FT, FFT> BoundLens<T, FT>.lens(
        crossinline get: FT.() -> FFT,
        crossinline set: FT.(FFT) -> FT
): BoundLens<T, FFT> {

    val value = get()
    return object : BoundLens<T, FFT> {
        override fun get() = value.get()
        override fun set(fv: FFT) = set(value.set(fv))
    }
}

fun <T> T.lensing() = object : BoundLens<T, T> {
    override fun get(): T = this@lensing
    override fun set(fv: T) = fv
}

inline fun <T, FT> BoundLens<T, FT>.modify(modifier: (FT)->FT) = set(modifier(get()))

This allows to define lens as follows:

data class A(val value: Int)

val <T> BoundLens<T, A>.value: BoundLens<T, Int>
    get() = lens(
            get = { value },
            set = { copy(value = it) }
    )

//usage
val a = A(24)
val newA = a.lensing().value.set(42)

Let's take more complicated example

data class Street(val number: Int, val name: String)
data class Address(val city: String, val street: Street)
data class Company(val name: String, val address: Address)
data class Employee(val name: String, val company: Company)

And write lenses:

val <T> BoundLens<T, Employee>.company: BoundLens<T, Company>
    get() = lens(
            get = { company },
            set = { copy(company = it) }
    )

val <T> BoundLens<T, Company>.address: BoundLens<T, Address>
    get() = lens(
            get = { address },
            set = { copy(address = it) }
    )

val <T> BoundLens<T, Address>.street: BoundLens<T, Street>
    get() = lens(
            get = { street },
            set = { copy(street = it) }
    )

val <T> BoundLens<T, Street>.name: BoundLens<T, String>
    get() = lens(
            get = { name },
            set = { copy(name = it) }
    )

Note that lenses above may are considered to be generated via annotating processing like original optics. So it shouldn't be written manually.

But we gain pretty nice syntax:

val employee = Employee("John Doe", Company("Kategory", Address("Functional city", Street(42, "lambda street"))))

val newEmployee = employee.lensing().company.address.street.name.modify { it.capitalize() }

Any thoughts?

Most helpful comment

@jereksel @nomisRev
Another consideration is that this syntax will be used only for setting and modifying values. Reading values can be done with regular properties dataObject.field.anotherField so there is no need in lensing() or any other syntax sugar.

So we can safely replace BoundLens with BoundSetter. The good point of it that any optic (lens, prisms, optionals, traversals) can be converted to a setter.

I think this is what we a looking for:

annotation class setters

interface BoundSetter<S, A> {
    fun modify(f: (A)->A): S
}

fun <T, S, A> BoundSetter<T, S>.setter(setter: Setter<S, A>): BoundSetter<T, A> {
    return object: BoundSetter<T, A> {
        override fun modify(f: (A)->A): T = [email protected] { setter.modify(it, f) }
    }
}

fun <T> T.setter() = object : BoundSetter<T, T> {
    override fun modify(f: (T)->T) = f(this@setter)
}

fun <S, A> BoundSetter<S, A>.set(a: A) = modify { a }

//primitives

val <T, A> BoundSetter<T, A?>.nullable    get() = setter(nullableOptional<A>().asSetter())
val <T, A> BoundSetter<T, Option<A>>.some get() = setter(somePrism<A>().asSetter())

//usage

@setters data class Street(val number: Int, val name: String)
@setters data class Address(val city: String, val street: Street)
@setters data class Company(val name: String, val address: Address)
@setters data class Employee(val name: String, val company: Company?)

val employee = Employee("John Doe", Company("Kategory", Address("Functional city", Street(42, "lambda street"))))
val newEmployee = employee.setter().company.nullable.address.street.name.modify { it.capitalize() }

All 12 comments

@qradimir I think this syntax is amazing and will be an excellent complement in the optics module. We probably would want to look into supporting other optics beside lenses, @nomisRev thoughts?

Love this, but there would be issues with nullables and lists (if we will generate traversals in the future).
Should it look like this?:

data class Street(val number: Int, val name: String)
data class Address(val city: String, val street: Street)
data class Company(val name: String, val address: Address)
data class Employee(val name: String, val company: Company?)

val employee = Employee("John Doe", Company("Kategory", Address("Functional city", Street(42, "lambda street"))))

val newEmployee = employee.lensing().companyNullable.address.street.name.modify { it.capitalize() }

We probably would want to look into supporting other optics beside lenses.

Absolutely love this syntax but couple of things we should try out and take into consideration.

  1. We'd be building new optics instead of using the existing ones. BoundLens vs Lens. Any way to work around that?
  2. Curious to how composition would work with different optics i.e. companyNullable as @jereksel mentioned. Or see example below for another example.
  3. If we are fine with 1 and cannot find a solution for 2, we might still want to add this feature because of the concise and lovely syntax for working with straight forward data classes as they might serve as a nicer alternative to copy.
sealed class Sum {
    data class A(val optStr: Option<String>): Sum()
    data class B(val str: String): Sum()
}

val sumA: Sum = A("example".some())
sumA.prisming().a.lensing().optStr.some.set("new-value") //A(Some("new-value")

val sumB: Sum = B("example")
sumB..prisming().a.lensing().optStr.some.set("new-value") //B("example")

@raulraja @pakoito @jereksel @qradimir

I don't like idea of new Lens - especially when BoundLens is just copy. .field should be syntactic sugar for compose fieldLens. Also I don't see reason for including prisming() and lensing() - I would just add optics() as extension method.

@jereksel I totally agree but having played with it a little bit I currently don't know a way how to pull that off :D

As BoundLens serves a different purposes as Lens since BoundLens is bound to the instance you call it on. BoundLens::set (A) -> S vs Lens::set (S) -> (A) -> S

@nomisRev I've made sample with other optics where sumA.optics().toA.optStr.some.set("new-value") syntax is achieved. You might take a look on it here

Also, it works on the top of original optics so I think there is no sense in replacing one optics with another. My idea is just for introducing syntax sugar, not for rewriting the whole library 馃槃

@jereksel @nomisRev
Another consideration is that this syntax will be used only for setting and modifying values. Reading values can be done with regular properties dataObject.field.anotherField so there is no need in lensing() or any other syntax sugar.

So we can safely replace BoundLens with BoundSetter. The good point of it that any optic (lens, prisms, optionals, traversals) can be converted to a setter.

I think this is what we a looking for:

annotation class setters

interface BoundSetter<S, A> {
    fun modify(f: (A)->A): S
}

fun <T, S, A> BoundSetter<T, S>.setter(setter: Setter<S, A>): BoundSetter<T, A> {
    return object: BoundSetter<T, A> {
        override fun modify(f: (A)->A): T = [email protected] { setter.modify(it, f) }
    }
}

fun <T> T.setter() = object : BoundSetter<T, T> {
    override fun modify(f: (T)->T) = f(this@setter)
}

fun <S, A> BoundSetter<S, A>.set(a: A) = modify { a }

//primitives

val <T, A> BoundSetter<T, A?>.nullable    get() = setter(nullableOptional<A>().asSetter())
val <T, A> BoundSetter<T, Option<A>>.some get() = setter(somePrism<A>().asSetter())

//usage

@setters data class Street(val number: Int, val name: String)
@setters data class Address(val city: String, val street: Street)
@setters data class Company(val name: String, val address: Address)
@setters data class Employee(val name: String, val company: Company?)

val employee = Employee("John Doe", Company("Kategory", Address("Functional city", Street(42, "lambda street"))))
val newEmployee = employee.setter().company.nullable.address.street.name.modify { it.capitalize() }

I really like where this is going @qradimir! 馃憦

Your last snippet is perfectly in line with I had in mind when I initially saw your idea yesterday. I updated the last example to include Traversal as well here. And it works amazing!

I am not sure this belongs in Optics tho. This is something that is build on top of Optics. Users work with the modify/set syntax and don't need any knowledge of Optics. It offers a elegant DSL to work with immutable data.

I'd just have this in the optics library under a syntax package or under dsl to be consistent with the approach in other arrow libraries. Other examples of this is map and tupled or binding where we provide higher level DSLs to deal with ap, product and flatMap

@raulraja just separate package or separate module?

If it isn't huge it'd be better in the same optics module IMO

Was this page helpful?
0 / 5 - 0 ratings

Related issues

pakoito picture pakoito  路  3Comments

JorgeCastilloPrz picture JorgeCastilloPrz  路  5Comments

gortiz picture gortiz  路  3Comments

lgtout picture lgtout  路  4Comments

raulraja picture raulraja  路  5Comments