Skip to content

Generic field access#361

Merged
arybczak merged 35 commits intomasterfrom
generic-field
Dec 13, 2020
Merged

Generic field access#361
arybczak merged 35 commits intomasterfrom
generic-field

Conversation

@arybczak
Copy link
Copy Markdown
Collaborator

@arybczak arybczak commented Oct 6, 2020

While working on https://gitlab.haskell.org/ghc/ghc/-/merge_requests/2965 I extracted code from generic-lens-lite for a perf test and made it fast at compile time (and fast at runtime with the above MR).

Taking #359 (comment) into consideration I'm tentatively for removing GeneralOpticLabel (and the pluggable incoherence it introduces) and provide generic field access.

You may think I'm contradicting myself since I was against generic field access, but:

  1. I didn't know that you can get gfield in under 100 lines of (modestly complicated) code with good error handling.
  2. After Leverage HasField to obtain field lenses in GHC >= 9.1 #359 field access with labels will get confusing due to setField (depending on GHC version), plugabble generic based access and TH.
  3. There is something to be said about the initial experience of a new user. If you can define a data type with a Generic instance and start using labels right away in a manner very similar to RecordDotSyntax without looking at TH and reading a guide in Optics.Label, it's a win.

Right now I'm thinking of

  • Adding gafield to Optics.AffineTraversal for partial fields (similar effort as gfield).
  • Integrating both into LabelOptic so that you get a Lens or an AffineTraversal depending whether the field you want is partial or not.

So then with #359 we'd have a better story:

  • Before GHC 9.2 you get type-changing, generic field access out of the box for convenience (and TH for type changing updates and speed)
  • After GHC 9.2 you get field access for:
    • partial fields: type-preserving Lens that might fail with HasField or AffineTraversal type error if the type has Generic instance.
    • total fields: type-preserving Lens with HasField machinery no matter if the type has a Generic instance or type-changing Lens if the type has a Generic instance (and type-changing is needed).

Though it's not yet clear how expensive is checking if a field is total.

That would also fix #140.

Thoughts? @phadej @adamgundry

@adamgundry
Copy link
Copy Markdown
Member

It'd be a shame to remove GeneralLabelOptic just after kcsongor/generic-lens#120 got in... on the other hand I do see that it would be nice to reduce the complexity by dropping the extension point. Would we simply drop any possibility of generic prisms (i.e. they would require TH/manual declarations)?

I like the idea of making partial fields safer and getting AffineTraversals for them. It's a shame HasField doesn't have a nice way to enable this at the moment. But I'm a bit worried by the confusion that could result if partial fields use HasField to get a lens in the absence of a Generic instance, but a generic affine traversal if there is such an instance. And it does seem like checking partiality at each use site might get expensive for large datatypes?

A thought that someone else brought up recently: could we use DerivingVia to require explicit (but simple) instance declarations for fields but give the user more control?

I'll continue thinking about this and look at the code, but I'm away on holiday for the next couple of weeks, so may not have much availability for a while.

@arybczak arybczak force-pushed the generic-field branch 6 times, most recently from a90168e to 54fbe68 Compare October 8, 2020 16:35
@arybczak
Copy link
Copy Markdown
Collaborator Author

arybczak commented Oct 8, 2020

It'd be a shame to remove GeneralLabelOptic just after kcsongor/generic-lens#120 got in...

The timing is indeed a bit unfortunate, but it was laying there for a couple of months (and my other PRs are still not merged). I'd like optics to provide simple and fast (both at compile and run time) solution, generic-lens currently struggles a little in this department.

Would we simply drop any possibility of generic prisms (i.e. they would require TH/manual declarations)?

I added prisms for constructors having up to 5 fields (should take care of 99% of cases).

But I'm a bit worried by the confusion that could result if partial fields use HasField to get a lens in the absence of a Generic instance, but a generic affine traversal if there is such an instance. And it does seem like checking partiality at each use site might get expensive for large datatypes?

Right, it's a bit problematic. I think the best way would be to offer generic affine traversals as a function, but not integrated as labels to avoid the confusion after we integrate with HasField.

Also, it's better to not encourage usage of partial field selectors as they frankly suck.

@arybczak arybczak force-pushed the generic-field branch 3 times, most recently from 0b895d8 to 51f5f08 Compare October 10, 2020 00:16
@arybczak arybczak changed the title WIP/POC: Generic field access Generic field access Oct 10, 2020
@arybczak arybczak force-pushed the generic-field branch 2 times, most recently from c83b28d to 76d7b00 Compare October 10, 2020 15:51
@arybczak
Copy link
Copy Markdown
Collaborator Author

arybczak commented Oct 10, 2020

Ok, I created Optics.Generic with GField, GAffineField and GConstructor (also moved GPlate there) and modified LabelOptic to use GField and GConstructor if appropriate.

@arybczak arybczak force-pushed the generic-field branch 2 times, most recently from ca44670 to 9a7959e Compare October 13, 2020 19:12
@arybczak
Copy link
Copy Markdown
Collaborator Author

arybczak commented Oct 13, 2020

I added support for type changing updates. Only one variant as they compile faster than type-preserving variants from generic-lens (not to mention type-changing ones) and have excellent type inference.

Inference from S
λ> data X ph a b = X1 String a Int | X2 b deriving (Show, Generic)
λ> x1 :: Prism (X ph a b) _ _ _; x1 = #_X1

<interactive>:46:24: error:
     Found type wildcard _ standing for X b1 b2 b
      Where: b1, b2, b are rigid type variables bound by
               the inferred type of
                 x1 :: Prism
                         (X ph a b) (X b1 b2 b) ([Char], a, Int) ([Char], b2, Int)
               at <interactive>:46:1-39
      To use the inferred type, enable PartialTypeSignatures
     In the second argument of Prism, namely _
      In the type Prism (X ph a b) _ _ _
      In the type signature: x1 :: Prism (X ph a b) _ _ _

<interactive>:46:26: error:
     Found type wildcard _ standing for ([Char], a, Int)
      Where: a is a rigid type variable bound by
               the inferred type of
                 x1 :: Prism
                         (X ph a b) (X b1 b2 b) ([Char], a, Int) ([Char], b2, Int)
               at <interactive>:46:1-28
      To use the inferred type, enable PartialTypeSignatures
     In the third argument of Prism, namely _
      In the type Prism (X ph a b) _ _ _
      In the type signature: x1 :: Prism (X ph a b) _ _ _

<interactive>:46:28: error:
     Found type wildcard _ standing for ([Char], b2, Int)
      Where: b2 is a rigid type variable bound by
               the inferred type of
                 x1 :: Prism
                         (X ph a b) (X b1 b2 b) ([Char], a, Int) ([Char], b2, Int)
               at <interactive>:46:31-39
      To use the inferred type, enable PartialTypeSignatures
     In the fourth argument of Prism, namely _
      In the type Prism (X ph a b) _ _ _
      In the type signature: x1 :: Prism (X ph a b) _ _ _
Inference from T
λ> x1 :: Prism _ (X ph a b) _ _; x1 = #_X1

<interactive>:47:13: error:
     Found type wildcard _ standing for X b1 b2 b
      Where: b1, b2, b are rigid type variables bound by
               the inferred type of
                 x1 :: Prism
                         (X b1 b2 b) (X ph a b) ([Char], b2, Int) ([Char], a, Int)
               at <interactive>:47:1-39
      To use the inferred type, enable PartialTypeSignatures
     In the first argument of Prism, namely _
      In the type Prism _ (X ph a b) _ _
      In the type signature: x1 :: Prism _ (X ph a b) _ _

<interactive>:47:26: error:
     Found type wildcard _ standing for ([Char], b2, Int)
      Where: b2 is a rigid type variable bound by
               the inferred type of
                 x1 :: Prism
                         (X b1 b2 b) (X ph a b) ([Char], b2, Int) ([Char], a, Int)
               at <interactive>:47:31-39
      To use the inferred type, enable PartialTypeSignatures
     In the third argument of Prism, namely _
      In the type Prism _ (X ph a b) _ _
      In the type signature: x1 :: Prism _ (X ph a b) _ _

<interactive>:47:28: error:
     Found type wildcard _ standing for ([Char], a, Int)
      Where: a is a rigid type variable bound by
               the inferred type of
                 x1 :: Prism
                         (X b1 b2 b) (X ph a b) ([Char], b2, Int) ([Char], a, Int)
               at <interactive>:47:1-28
      To use the inferred type, enable PartialTypeSignatures
     In the fourth argument of Prism, namely _
      In the type Prism _ (X ph a b) _ _
      In the type signature: x1 :: Prism _ (X ph a b) _ _

It can even change phantom type variables.

@arybczak arybczak force-pushed the generic-field branch 3 times, most recently from 1f98cac to f92ccfa Compare October 14, 2020 18:47
@arybczak
Copy link
Copy Markdown
Collaborator Author

Compile time benchmarks from https://github.com/arybczak/generic-lens/tree/perf-work (cabal build generic-optics:perf) with GHC 8.10.2.

field',field, position' and position are from generic-optics library, L.field is from generic-optics-lite library (@phadej have a look, it's very inefficient).

1 constructor:

variant memory time
gfield 296M 1.95s
field' 342M 2.91s
field 329M 3.37s
L.field 1085M 14.88s
gposition 304M 2.05s
position' 1406M 26.17s
position 1022M 66.01s

2 constructors:

variant memory time
gfield 415M 3.71s
field' 544M 5.97s
field 592M 6.69s
L.field 1359M 25.77s
gposition 406M 3.90s
position' 2208M 32.67s
position 2993M 73.46s

3 constructors:

variant memory time
gfield 514M 4.51s
field' 714M 7.75s
field 827M 9.07s
L.field 1959M 35.18s
gposition 507M 4.81s
position' 2602M 50.10s
position 3848M 108.9s

@arybczak arybczak force-pushed the generic-field branch 7 times, most recently from 090569c to 21c4a08 Compare October 22, 2020 09:36
@phadej
Copy link
Copy Markdown
Contributor

phadej commented Nov 30, 2020

It uses generics only for setting, so then if the data type is large enough (>10 fields for a pure product) and they don't optimize away it's going to be a couple of times slower than hand-written/TH generated one.

I would prefer that to be an error rather than a (hard to spot in large) slowdown, if I forget to TH generate an instance.

@arybczak
Copy link
Copy Markdown
Collaborator Author

arybczak commented Nov 30, 2020

I would prefer that to be an error rather than a (hard to spot in large) slowdown, if I forget to TH generate an instance.

Right, I see your point. I'm just unsure how to solve it in a satisfactory way.

I thought this PR will allow libraries to use labels with optics-core :(

Perhaps we could add a GenericOptics class that has a trivial associated type family (so that we can detect whether a type derived it with the stuck type family trick) for the generic instance to work?

@arybczak
Copy link
Copy Markdown
Collaborator Author

@phadej does 65194d5 (Require GenericLabelOptics instance for generic labels) satisfy you?

@phadej
Copy link
Copy Markdown
Contributor

phadej commented Nov 30, 2020

@arybczak yes, that is great for me. But indeed, I'm quite sure there are people who would prefer having generic labels for everything on by default. I don't see a way to satisfy everyone (thus why I'm fine with cabal flag for my-pedantic-self).

@arybczak
Copy link
Copy Markdown
Collaborator Author

arybczak commented Dec 1, 2020

Yeah. That covers the case of libraries wanting to use generic labels with their data types and applications wanting to use generic labels in general (as if a specific library is missing the instances, orphans can be provided), but it doesn't work for libraries wanting to use generic labels with data types from other libraries not knowing about optics :( Well, they still can, but need to use gfield instead of a label.

Ideally I wanted this to create a sort of compatibility layer for sane field handling with labels from now on until we get SetField - as then the overlappable instance in optics would just use SetField for setting instead of generics and no changes would be necessary to code that uses optics.

But I do see the problem of using generics silently if one forgets to define LabelOptic instances (either by hand or via TH)...

At least this is better than the cabal flag, since with a flag no library can use generic labels.

I'll let it marinate for another week or so, maybe someone will have an enlightenment how to solve it gracefully.

@adamgundry
Copy link
Copy Markdown
Member

At least this is better than the cabal flag, since with a flag no library can use generic labels.

What about having a manual flag biased towards allowing generic labels:

  • By default, labels use generics silently. Libraries are implicitly allowed to assume this will be the case.
  • If the flag is set, the generic instance is replaced with one that uses TypeError.

That way users who really care about performance can set the flag at build time and guarantee the absence of generic instances. Of course they might hit the problem that some library they depend on uses a generic instance from another library they depend on, and the build will fail. But if they did explicitly ask for that...

We can alter the overlappable instance with the cabal flag, but I personally am not a fan of having a flag change API in such a subtle way.

I generally agree that flags shouldn't change APIs, but in this case we are really just saying we want to build in "hard mode" as an additional check on the build. And if that is explicitly requested by the person building the package, it seems okay to me.

Really we want some way to (optionally) request a warning when a certain instance is used, which is occasionally requested as a GHC feature.

@arybczak
Copy link
Copy Markdown
Collaborator Author

arybczak commented Dec 2, 2020

@adamgundry I was thinking about a similar thing - manual flag, enabled by default and explicit note that turning it off is generally unsupported.

That would render the flag unusable once/if libraries rely on it enough. On the other hand, the GenericLabelOptics approach would most likely mean that labels will never gain enough traction 🙂 at least before SetField.

Choices...

@arybczak
Copy link
Copy Markdown
Collaborator Author

arybczak commented Dec 8, 2020

I think I have a middle ground solution.

I added explicit-generic-labels flag to optics-core (disabled by default). When disabled, only the Generic instance is needed for the overlappable LabelOptic instance to fire. When enabled, GenericLabelOptics instance is needed in addition.

This gives more flexibility to application authors as they can enable this flag and patch/send PRs to affected libraries to simply derive GenericLabelOptics (or include specific LabelOptic instances) for offending data types.

@phadej @adamgundry what do you think?

@arybczak
Copy link
Copy Markdown
Collaborator Author

arybczak commented Dec 9, 2020

This looks satisfactory to me, I'll merge (this time for sure 😛 ) during the weekend.

Copy link
Copy Markdown
Member

@adamgundry adamgundry left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I'm happy with this approach. I've made some suggestions regarding documentation.

I was initially worried that it is unclear under what circumstances it would make sense for libraries to provide GenericLabelOptics instances, and hence wondering whether the class is useful in addition to the flag. But I think it is fine to say they shouldn't normally bother, yet keep the class as an override mechanism so that an application developer can choose to build with -fexplicit-generic-labels and potentially define (possibly orphan) GenericLabelOptics instances.

Comment thread optics-core/src/Optics/Generic.hs Outdated
Comment thread optics-core/optics-core.cabal
Comment thread optics-core/src/Optics/Generic.hs Outdated
Comment thread optics-core/src/Optics/Label.hs Outdated
Comment thread optics-core/src/Optics/Label.hs Outdated
Comment thread optics-core/src/Optics/Label.hs Outdated
Comment thread optics-core/src/Optics/Label.hs
Comment thread optics-core/src/Optics/Generic.hs Outdated
arybczak and others added 4 commits December 11, 2020 12:07
Co-authored-by: Adam Gundry <adam@well-typed.com>
Co-authored-by: Adam Gundry <adam@well-typed.com>
Co-authored-by: Adam Gundry <adam@well-typed.com>
Co-authored-by: Adam Gundry <adam@well-typed.com>
@arybczak
Copy link
Copy Markdown
Collaborator Author

arybczak commented Dec 11, 2020

But I think it is fine to say they shouldn't normally bother

Libraries shouldn't bother unless they themselves use generic labels with their data types, then it makes sense for them to derive these instances so that the library works with explicit-generic-labels (I added this to the doc).

@arybczak
Copy link
Copy Markdown
Collaborator Author

@adamgundry I applied your suggestions, I think we're good to merge this 🎉

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

Successfully merging this pull request may close these issues.

OverloadedLabels requires either manually writing instances or using TemplateHaskell

3 participants