Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Have you considered using lenses for updates? #19

Closed
dead-claudia opened this issue Jun 7, 2019 · 18 comments
Closed

Have you considered using lenses for updates? #19

dead-claudia opened this issue Jun 7, 2019 · 18 comments

Comments

@dead-claudia
Copy link
Contributor

dead-claudia commented Jun 7, 2019

For those who aren't familiar with lenses:

I know this sounds a bit like I'm getting ready to suggest some obscure functional programming concept, but I'm not.

Lenses are simple {get(o): v, set(o, v): o} pairs. They are really nice for operating on nested data without having to deal with all the boilerplate of the surrounding context. They are easy to write and easy to compose.

// Gets and sets `b` inside of `a`
function nest(a, b) {
    return {
        get: o => b.get(a.get(o)),
        set: (o, v) => a.set(o, b.set(a.get(o), v)),
    }
}

// Updates a value inside a view
function modify(o, lens, func) {
    return lens.set(o, func(lens.get(o)))
}

// Updates a value inside a view path
// Called as `modifyIn(o, func, ...lenses)`
function modifyIn(o, func, lens, ...rest) {
    if (rest.length === 0) return modify(o, lens, func)
    return modifyIn(o, (v) => modify(v, lens, func), ...rest)
}

That modify operation is really where all the power in lenses lie, not really the get or set.

Edit: Forgot to double-check some variable names.
Edit 2: Fix yet another bug. Also, make it clearer which edits apply to this section.


Edit: clarity, alter precedence of function call vs lens
Edit 2: Update to align with the current proposal

Lenses are pretty powerful and concise, and they provide easy, simple sugar over just direct, raw updates. But it kind of requires revising the model a bit:

  • Getting properties could be something like object.@lens
  • Setting properties could be something like object with .@foo.@bar.@baz = value, ...
  • Updating properties could be something like object with .@foo.@bar.@baz prev => next, ...
  • Sugar would exist for properties, using .prop instead of .@lens
  • Sugar would exist for indices, using [key] instead of .@lens
  • In each of these, you can do stuff like .@foo(1, 2, 3).@bar("four", "five", "six") and so on. The functions would be applied eagerly before actually evaluating updates. However, member expressions like @(foo.bar) need parenthesized.
  • Yes, you can merge this all, like in object with .@foo.bar[baz] prev => next, ....

Of course, I'm not beholden to the syntax here, and I'd like to see something a little more concise.

For a concrete example, consider this:

const portfolio = #[
    { type: "bond", balance: 129.46 },
    { type: "bond", balance: 123.54 },
];

const updatedData = portfolio
    with [0].balance = addInterest(portfolio[0].balance, 1.92),
         [1].balance = addInterest(portfolio[1].balance, 1.25);

assert(updatedData === #[
    { type: "bond", balance: ... },
    { type: "bond", balance: ... },
]);

With my suggestion, this might look a little closer to this:

const portfolio = #[
    { type: "bond", balance: 129.46 },
    { type: "bond", balance: 123.54 },
];

const updatedData = portfolio
    with [0].balance prev => addInterest(prev, 1.92),
         [1].balance prev => addInterest(prev, 1.25);

assert(updatedData === #[
    { type: "bond", balance: 129.46 * (1 + 1.92/100) },
    { type: "bond", balance: 123.54 * (1 + 1.25/100) },
]);

const incrementWages = data =>
    data with .byCity = data.byCity.map(
        ([city, list]) => #[city, list.map(
            item => item with hourlyWage = item.hourlyWage + 1
        )]
    )

If you wanted to push or pop in the middle, you could do this:

record with .list l => l.pop()

Lenses would have to have get and set, but if you make the set to always just an update function, you could also make it a little more useful and powerful. You could then change it to this:

  • Get: object.@lenslens.get(object)
  • Update: object with .@lens by funclens.update(object, func)
  • Sugar: object with .@lens = valueobject with .@lens by () => value
  • Sugar: object with .@lens += valueobject with .@lens by prev => prev + value
  • Chaining: object with .@lens.@lens, object with .@lens.@lens = value, etc.
  • Lens: {get(object), update(object, ...args)}
  • .key and [index] can be used in place of a lens as sugar for using the lens @updateKey("key") and @updateKey(index)
    • Static: object with .key by update, object with .key = value
    • Dynamic: object with [index] by update, object with [index] = value
    • updateKey(key) here is an internal lens that returns the rough equivalent of {get: x => x[key], update: v => ({...v, [key]: value})}. This is not exposed to user code, and simple property access could be altered to be specified in terms of this.
  • In the future, this could be extended to work with mutable properties via x.@lens = value and x.@lens(update) as procedural operations.

And of course, it'd work similarly:

const portfolio = #[
    { type: "bond", balance: 129.46 },
    { type: "bond", balance: 123.54 },
];

const updatedData = portfolio
    with [0].balance(prev => addInterest(prev, 1.92)),
         [1].balance(prev => addInterest(prev, 1.25));

assert(updatedData === #[
    { type: "bond", balance: 129.46 * (1 + 1.92/100) },
    { type: "bond", balance: 123.54 * (1 + 1.25/100) },
]);

const incrementWages = data =>
    data with .byCity = data.byCity.map(
        ([city, list]) => #[city, list.map(
            item => item with .hourlyWage += 1
        )]
    )

However, the real power of doing it that way is in this:

// No, really. This is actually *that* concise and boilerplate-free.
const each = {update: (list, func) => #[...[...list].map(func)]}
const incrementWages = data =>
    data with .byCity.@each[1].@each.hourlyWage += 1

Here's what a transpiler might desugar that to:

const each = {update: (list, func) => list.map(func)}
const incrementWages = data =>
    #{...data, byCity: each.update(data.byCity,
        pair => #[pair[0], each.update(pair[1],
            item => #{...item, hourlyWage: item.hourlyWage + 1}
        ), ...pair.slice(2)]
    )}
@Wollantine
Copy link

There are ways of implementing lenses without having to provide a nest function, because lenses are usually functions and should be composable just as functions are: compose(lensA, lensB) === x => lensA(lensB(x)).

It makes sense to me that .a is syntax sugar for a lensProp('a') (a lens with get x => x.a and set (v, x) => #{...x, a: v}).

This way, two wonderful things happen:

  • data.a.b.c is actually syntax sugar for view(compose(.a, .b, .c), data), and
  • data with .a.b.c = 4 is actually syntax sugar for set(compose(.a, .b, .c), data, 4)

The only problem would be with array prop lenses, because when used without with keyword, they would look the same as an array creation. A possible solution would be forcing the initial dot, like follows:

data with .[1] = 0, or set(.[1], data, 0)

.[1] here would be a lens for the prop 1.

@dead-claudia
Copy link
Contributor Author

@kwirke I considered "set" instead of "update" initially (as shown in the bug itself), but there were a few things I ran into:

  • It's often the case that a combined "update" function is faster than a separate "get" + "set", and it's never slower (minus the polymorphic function call overhead).
  • "Set" with a raw value is rarely slower than "update" with a thunk ignoring the previous value in practice, and with collections, using the return value of a thunk leads to more expected semantics anyways. (Consider array with @each = Math.random() - you probably would expect that to not return the same number repeated array.length times, but it'd have no choice not to if "set" used raw values!)
  • You can't penetrate a collection (or any functor, for that matter) with a lens if it's defined as "get" + "set", but you can if it's defined as "get" + "update", so it's more general. Consider my each lens above - you can't implement this with "get" + "set", but you can with "update".
  • You can still compose "update" like you can "set" - it just requires a bit more indirection. The indirection is similar to that of liftA2 sugaring over ap.

Here's how composition would compare between the two:

// "get" + "update"
function composeUpdate(a, b) {
	return {
		get: v => b.get(a.get(v)),
		update: (v, f) => a.update(v, (u) => b.update(u, f)),
	}
}

// "get" + "set"
function composeSet(a, b) {
	return {
		get: v => b.get(a.get(v)),
		set: (v, x) => a.set(v, b.set(a.get(v), x)),
	}
}

If you notice, the "update" version is slightly simpler but requires an extra level of lambda abstraction.

@Wollantine
Copy link

Hi @isiahmeadows ,

I don't think I explained myself well, because I think we agree on this. When I talked about set, I was actually talking about your update, just basing myself on the syntax for lenses in the Ramda library.

About using thunks, that is also possible with over:

const add3 = x => x + 3
over(compose(.a, .b, .c), data, add3)

What I'm saying is that, in the implementations I have seen, lenses are decoupled from get/set/over functions, so they are reusable and only coupled to the shape of the type they are lensing.

@dead-claudia
Copy link
Contributor Author

@kwirke

lenses are decoupled from get/set/over functions

I'm aware, and you could do it that way. In library implementations, it's also way easier to implement this way, but of course, syntax does offer you flexibility in how you present them that libraries don't have. And of course, my suggestion above is just a different syntax for the same thing - data with .@foo += 1 is directly equivalent to foo.update(data, (x) => x + 1).

But of course, there's a key difference here: I'm aware of literally no prior language (aside from the one I'm privately developing - closed source ATM, sorry!) that integrates lenses into the core language itself. Clojure is about the closest I've ever seen, but that only merges objects and hash maps with arbitrary key types.* Lenses are obviously more general than that.

* Python technically does make objects backed by non-string hash maps, but they aren't merged at the conceptual level, so I'm not counting it here.

@dead-claudia dead-claudia mentioned this issue Jun 27, 2019
@tolmasky
Copy link

tolmasky commented Jun 27, 2019

I mentioned this in the with syntax section, in (#1 (comment)), but I think that keyPaths are the feature we should actually be pushing for. Specifically, being able to provide multiple keys in computed properties:

{ ...a, [key1, key2, key3]: 5 } === { ...a, [key1]: { ...a[key1], [key2]: { ...a[key1][key2], [key3]: 5 } } }

I always prefer data over special syntax, as it allows you to do something like this:

{ ...a, [...keyPath]: 7 }

This is equivalent to a "set" lense as is, and makes it trivial to implement an update lense in userspace:

const updated = update(x,
{
    [key1, key2]: previous => f(previous),
    [key1, key3]: previous => g(previous),
    [key4]: previous => r(previous)
});

// Which is equivalent to having manually typed out the following:

const updated = update(x,
{
    key1: { key2: previous => f(previous), key3: previous => g(previous) },
    key4: previous => r(previous)
});

Where the implementation of update is:

function update(target, updates)
{
    if (typeof updates === "function")
        return updates(target);

    const changes = Object
        .entries(updates)
        .map(([key, value]) => [key, update(target[key], value)]);

    return Array.isArray(target) ?
        changes.reduce((array, [key, value]) =>
            (array[key] = value, array), [...target]) :
        { ...target, ...Object.fromEntries(changes) };
}

You can see a working example of this here: https://runkit.com/tolmasky/key-path-update

@dead-claudia
Copy link
Contributor Author

@tolmasky This isn't fundamentally incompatible with that and could be altered to work with it. The general idea is abstracting the concept of a "key" and an "update", so things like push and pop can be represented within the model without requiring explicit syntax for it. It could work with "key" paths - my syntax was just using the with as an example.

// Assuming you add `+:`/etc. operators to properties
const incrementWages = data =>
    {...data, ["byCity", @each, 1, @each, "hourlyWage"]+: 1}

@tolmasky
Copy link

tolmasky commented Jun 27, 2019

I suppose my only point is that through the keyPath syntax, the ergonomic value of dedicated update syntax decreases:

const incrementWages = data =>
    {...data, ["byCity", @each, 1, @each, "hourlyWage"]+: 1}

Isn't that much more terse than:

const incrementWages = data =>
    update(data, ["byCity", update.each, 1, update.each, "hourlyWage"]: x => x + 1 }

Where update.each here is a Symbol that can be uniquely keyed off from in the update function, and which could thus theoretically be expanded upon by users as well, for example Symbol("odd") or something.

Without a data-driven keyPath syntax, it becomes hard to "serialize" and move the update path into a userland function, IMO motivating the desire for things like "+:" to piggy back off of the existing keying methods, vs. having to do something like update(x, [["a","b","c","d"], x => x + 1, [["c",d"], x => x + 2]) which does feel significantly less ergonomic.

@dead-claudia
Copy link
Contributor Author

Not a fan of expanding symbols to also wrap lenses. I'd rather keep that difference a bit more explicit, and if lenses become primitives, I'd rather keep it a separate primitive type altogether.

Without a data-driven keyPath syntax, it becomes hard to "serialize" and move the update path into a userland function

That's not hard: you just need to nest it in an update.

const eachCityEmployee = {
	update: (data, func) =>
		update(data, ["byCity", update.each, 1, update.each], func)
}

update(data, [eachCityEmployee, "hourlyWage"], x => x + 1)

I did consider a purely library concept of this, but I wasn't sure it would gain enough traction with TC39, so I chose to omit it.

@tolmasky
Copy link

tolmasky commented Jun 27, 2019

I think maybe there is some misunderstanding here (potentially completely on my part).

My point about serializing keyPaths was in response to the perceived benefits/necessity of a built-in +: syntax (and what I imagine are a flurry of other declarative update-related operators). As your nested update example seems to demonstrate, the double nested update is significantly more text, and indirection, than the +: version you originally provided. Comparing just those two, I would agree that some built-in shorthand would be desirable over reliance on user-methods. However, the combo userland-update with built-in keyPath syntax trick I provided is not that much more text than the +: shorthand (especially if you were to just var out the update.each). In fact, I'd argue they are nearly equivalent without needing multiple new syntax forms beyond the keyPath suggestion:

const incrementWages = data =>
    {...data, ["byCity", @each, 1, @each, "hourlyWage"]+: 1}

const incrementWages = data =>
    update(data, { ["byCity", each, 1, each, "hourlyWage"]: x => x + 1 });

Arguably most the difference comes from the manual definition of the +1 lambda. If we were to be employing any more sophisticated update function, they'd truly be nearly identical. This was really my only point with all that.

That being said, I wasn't 100% sure I understood the update wrapping (As it doesn't work with my update function, but perhaps you were implying some sort of change to it, or referencing a previous update function from an earlier comment that I missed). Separately, I am also not sure if the goal is to create easily consumable syntax or some sort of under-the-hood speed for "built-in" update lenses. I have so far focused on the former as I am not a fan of creating new syntax for performance reasons, at least not this early on, so I can't really speak to the latter.

@dead-claudia
Copy link
Contributor Author

I think maybe there is some misunderstanding here (potentially completely on my part).

Partially, but not completely. 😉

Arguably most the difference comes from the manual definition of the +1 lambda. If we were to be employing any more sophisticated update function, they'd truly be nearly identical. This was really my only point with all that.

The way you're explaining it, it seems to line up like this:

// Yours
{ ["byCity", each, 1, each, "hourlyWage"]: x => x + 1 }

// Userland
data => update(data, nest("byCity", each, 1, each, "hourlyWage"), x => x + 1)

The object literal could be desugared to just multiple update + nest calls like above, where you have one call for each.

That being said, I wasn't 100% sure I understood the update wrapping (As it doesn't work with my update function, but perhaps you were implying some sort of change to it, or referencing a previous update function from an earlier comment that I missed).

My update ≠ your update. If you look in the code snippet here, the composeUpdate is equivalent to my nest above, and update(data, lens, func) is just lens.update(data, func) with a little sugar for strings, numbers, and symbols being wrapped into appropriate lenses first. So if you wanted a more accurate depiction, it'd be mostly this:

function update(obj, view, func) {
	if (view != null && (
		typeof view === "object" || typeof view === "function"
	)) {
		return {...obj, [view]: func(obj[view])}
	} else {
		return view.update(obj, func)
	}
}

Separately, I am also not sure if the goal is to create easily consumable syntax or some sort of under-the-hood speed for "built-in" update lenses. I have so far focused on the former as I am not a fan of creating new syntax for performance reasons, at least not this early on, so I can't really speak to the latter.

I'm similarly focusing on the former, and have specifically refrained from bringing up "built-in" lenses aside from syntactic support and the minimum required to integrate existing properties with them (which I specify as special cases, not actual lenses).

@dead-claudia
Copy link
Contributor Author

dead-claudia commented Aug 12, 2019

Update: maybe tack on @lens: value properties, too, as sugar for {...prevProps} with .@lens = value, ...otherNewProperties.

I'll eventually centralize this all into a gist.

Edit: One concrete application is making stuff like React Flare's event handlers feel basically like native properties despite them being more or less specified in userland. You could also have an h("a", {@href: "/some/route"}, ...) where href just sets a click event and an href attribute.

@mheiber
Copy link
Contributor

mheiber commented Aug 14, 2019

Could you summarize the motivation for using lenses here? I took a stab at getting this started:

Our goals with this proposal are given in the overview.

  1. In terms of our goals, what are the advantages of lenses as compared to the existing proposal?
    • I suspect this has something to do with more complex transformations in deeply-nested data, but don't know enough about lenses.
  2. In terms of our goals, what are the disadvantages of lenses as compared to the existing proposal?
    • efficient cloning and comparison are primary goals of the current proposal. Can lenses be implemented efficiently? I'm worried that if they are syntactic sugar for a ton of function calls that might be more difficult to optimize, but I'm a little out of my depth here. I'm a little unclear on what notion of 'sugar' is at play here. Would the desugared syntax be available as well as the sugary one?

@dead-claudia
Copy link
Contributor Author

dead-claudia commented Aug 16, 2019

@mheiber

I suspect this has something to do with more complex transformations in deeply-nested data, but don't know enough about lenses.

This is pretty accurate.

efficient cloning and comparison are primary goals of the current proposal. Can lenses be implemented efficiently? I'm worried that if they are syntactic sugar for a ton of function calls that might be more difficult to optimize, but I'm a little out of my depth here. I'm a little unclear on what notion of 'sugar' is at play here. Would the desugared syntax be available as well as the sugary one?

The desugared syntax will itself always accessible. Lenses as proposed here would just be simple {get: (value) => result, update: (value, func) => updated} objects. Engines can of course optimize that very effectively. The main issue is around heavier use of temporary functions, but in my experience, those aren't as expensive as people often believe. Edit: I've even in a few occasions gotten away with higher-order functions in normally performance-sensitive code, even outside the world of builtins where engines will sometimes inline the functions in (especially with promises, but even with .map and friends more recently).

With functional programming, where engines really struggle are when you're returning functions, not simply receiving them. It's stuff like map(func)(list) where map = f => xs => ... or the module factory pattern of (...args) => ({...methods}) that they tend to struggle to properly inline and optimize stuff. My proposal here doesn't hit those problem areas.


It wasn't clear iniitally because it took the process of filing the bug and answering a few questions to figure it out, but my precise proposal currently can be summarized as this:

  • value.@keykey.get(value)
  • value with .@key = valuekey.update(value, () => value)
  • value with .@key by exprkey.update(value, expr) (note: by is unambiguous here)
  • value with .key by exprvalue with .key = (expr)(value.key)
  • value with [key] by exprvalue with [key] = (expr)(value[key])
  • value with .one.@two.three by exprvalue with .one by (a) => two.update(a, (b) => b with .three by expr)
  • value with .@one by foo, .@two by bartwo.update(one.update(value, foo), bar)
  • .@key is specifically just `.` `@` Identifier Arguments[opt] | `@` `(` Expression `)`.

@mheiber
Copy link
Contributor

mheiber commented Aug 16, 2019 via email

@dead-claudia
Copy link
Contributor Author

@mheiber In the initial comment, here's an example:

// Current proposal
const incrementWages = data =>
    data with .byCity = data.byCity.map(
        ([city, list]) => #[city, list.map(
            item => item with .hourlyWage += 1
        )]
    )

// My suggestion here
const each = {update: (list, func) => list.map(func)}
const incrementWages = data =>
    data with .byCity.@each[1].@each.hourlyWage += 1

// Desugaring
const each = {update: (list, func) => list.map(func)}
const incrementWages = data =>
    data with .byCity = each.update(data.byCity,
        (pair) => pair with [1] each.update(pair[1],
            item => item with .hourlyWage += 1
        )
    )

// After JIT inlining - basically the same as the current proposal
const incrementWages = data =>
    data with .byCity = data.byCity.map(
        (pair) => pair with [1] = pair[1].map(
            item => item with .hourlyWage += 1
        )
    )

Makes nested updates inside things other than simple single const value members much easier and less boilerplatey.

@mheiber
Copy link
Contributor

mheiber commented Aug 17, 2019

Thanks for summarizing!

Could the lense syntax work as a follow-on proposal? I'm hoping the const value types proposal will be bite-sized, but it would be neat if we didn't rule out future ergonomics things.

@dead-claudia
Copy link
Contributor Author

Wouldn't be against it, provided the main proposal doesn't prevent it from happening.

@rricard
Copy link
Member

rricard commented Sep 10, 2019

We removed with from the proposal. This can be considered as a separate proposal now.

@rricard rricard closed this as completed Sep 10, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

5 participants