Dedicated syntax for overriding nested record field

Because we try to reuse and combine objects parts coming from different libraries or APIs of our stack, our OCaml record often look like Matryoshka dolls, eg. we access a task notes via task.remote.writable.templatable.notes. It’s a bit verbose, but has many great properties we like in term of factoring types – I could elaborate but this is not the point of this post.

The part that is however extremely painful is overriding a deeply nested field, à la { record with field = new_value }:

{
          task with
          remote=
            {
              task.remote with
              writable =
                {
                  task.remote.writable with
                  templatable = { task.remote.writable.templatable with notes };
                };
            };
        }

I’m dreaming of a simpler syntax that would simply read :

{ task with remote.writable.templatable.notes = notes }

Or event omitting the value name if its name is the same as the last field’s:

{ task with remote.writable.templatable.notes }

It seems to me that this is a dead corner of the grammar so it would not impact backward compatibility and would be a nice quality of life improvement, event for more common not-as-deeply-nested records, eg. { object with meta.etag = None }.

I’m curious about the general feeling of the community about such a syntax.

Cheers,

3 Likes

Deep functional record updates is the only thing missing imo from record syntax to make lenses moot. I’d love to see it happen.

There seems to be some discussion about this here: Extension of "record with" to allow deep field assignment like: { r with f.g.h = e } by rbardou · Pull Request #291 · ocaml/ocaml · GitHub

3 Likes

Thanks for the pointer! I see several legitimate points, mostly about the order of evaluation being changed if multiple updated path overlap. I think it’s not that hard to address, we could simply expect overlapping paths to be grouped in the source code, or warn if they are not. If we stick to handling this as a syntactic sugar, rhs expressions could also be computed and stored in local variables in the right order before being used, and this could be triggered only if reordering is needed.

In other words:

{
  player.position.x = side_effect_1 (); 
  player.speed = side_effect_2 ();
  player.position.y = side_effect_3 ();
}

Could be rewritten as:

let x = side_effect_1 ()
and speed = side_effect_2 ()
and y = side_effect_3 ()
in
{ player with position = { x; y }; speed }

(We should use fresh unique identifiers, but that’s the gist of it)

I have an (unreleased) implementation of this as a ppx. It’s very simple and currently makes no guarantees about evaluation order. Didn’t know about the prior attempt to integrate this in the compiler, but since this is doable purely as a Parsetree transformation, a ppx seems adequate.

6 Likes

That is a great workaround, thanks!

I know that’s not the solution you’re currently going for, but a lens library can solve this issue in a good way too.

The idea is that it’s possible to define a ('r, 'a) lens = ('r -> 'a) * ('a -> 'r -> 'r) as a way to get and set “part of” a value. This applies to record fields and compose nicely. You can even generalize the idea to components of a tuple, fixed elements of an array, etc.

Using an abstraction like that, instead of { task with remote.writable.templatable.notes = notes }, you’d call a function like set (remote & writable & templatable & notes) new_notes. There’s an example in the lens README. ppx_fields_conv might be a good entry to that too but I’m not sure it exposes a composition API.

3 Likes

Your rewriter prompted the following experimentation; I like the approach, but I’d rather have a syntax closer to the one I initially described. { x with y.z } is not valid syntactically, but we can leverage fields module qualification via { x with Y.z }. The need to qualify a field name in such construct will probably never arise since the base record value type is known.

Quick draft: GitHub - mefyl/ocaml-deep-lense: OCaml PPX rewriter for deep functional record lenses

This is a quick PoC, order of evaluation and other caveat are not yet addressed. Also in hindsight, deep-lense is probably not such a great name since this does not implement actual lenses. The interesting part to me is that using this PPX could serve as a test bench for how well this syntax would integrate in the actual language since it’s only one extension point and a few uppercase letters away from the real thing.

Cheers,

Sweet! Is there a reason you went with a different syntax, viz.

[%r
      lib.book.borrowed_by.name <- "Louis";
      lib.book.title <- "Dragon";
      lib.book.borrowed_by.id <~ fun x -> x + 1]

instead of just extending record-update syntax as in

[%r { lib with book.borrowed_by.name = "Louis";
              book.title = "Dragon";
              book.borrowed_by.id = [%apply fun x -> x + 1] }]

?

Perhaps it’s because you wanted to be able to use “<~”, and with record-update, you’re stuck with “=” and nothing else. But maybe one could use a special prefix operator to indicate “apply the value as a function” or (as I hastily scribbled above) a second PPX extension node.

Oh of course, silly me, you can’t parse that syntax or more exactly, ocaml can’t parse it.

Yup. <- just seemed most natural because it’s used for mutable record updates and allows a path on the left (and <~ seemed like a natural extension). An extension node or prefix operator instead of <~ would probably be better if we wanted to add more kinds of updates.

The need to qualify a field name in such construct will probably never arise since the base record value type is known.

Interesting idea. I’m not sure about this point though… the base type may need to be qualified.

We could use Y.{ x with z } instead of { x with Y.z } sometimes, or use some kind of prefix to disambiguate, but overloading module qualification seems a bit awkward.

I made that point because for now I only support { x with ... }. Indeed without a base value one can just qualify the whole record. I agree it’s a bit strange to override this part of the grammar, but in could enable to test the almost-real thing to find any potential issue.

Sounds like my little trick using lenses and existing index syntax: Just reinvented OOP - #7 by yawaramin