How to use lens to access a record field behind a sum type
Asked Answered
C

2

5

I am trying to access a nested record using lenses and prisms in Haskell:

import Data.Text (Text)
import Control.Lens.TH

data State = State
    { _stDone :: Bool
    , _stStep :: StateStep
    }

data StateStep
    = StatePause
    | StateRun
        { _stCounter  :: Int
        , _stMMistake :: Maybe Text
        }

makeLenses ''State
makeLenses ''StateStep
makePrisms ''StateStep

main :: IO ()
main = do
    let st = State False $ StateRun 0 Nothing

    -- works, but the `_2` seems weird
        mMistake = st ^? stStep . _StateStepRun . _2 . _Just

    -- why not something like (the following does not compile)
        mMistake = st ^. stStep . _StateStepRun . _Just . stMMistake

The line that works leaves some questions open. I am unsure whether or not the type match by coincidence. The field _stMMistake has type Maybe Text, but what about

let st = State False StatePause

? I am missing the explicit join.

And I am clueless about how prisms work. While it seems logical for the prism to give me a tuple, at the same time I expected something composable in the sense that I can go deeper into my nested structure, using lenses. Do I have to derive my instances manually for this, maybe?

Cacophony answered 28/2, 2022 at 21:52 Comment(2)
Why does the _2 bug you? You said it makes sense that you ended up with a tuple, and _2 accesses the second element of the tuple. Haskell's type system is fairly strict; it's not going to let you skip steps like your second example seems to be doing.Comeaux
@SilvioMayolo I was expecting to use stMMistake to access deeper down after _StateStepRun. The _2 isn't exactly expressive as the original data type does not contain tuples.Cacophony
W
5

Updated: As per comments, I've fixed some errors and added a few asides in [[double square brackets]].

Here's how/why your first mMistake works...

A prism is an optic that focus on a "part" that may or may not be present in the "whole". [[Technically, it focuses on the sort of part that can be used to reconstruct an entire whole, so it really pertains to a whole that can come in several alternative forms (as in the case of a sum type), with the "part" being one of those alternative forms. However, if you're only using a prism for viewing and not setting, this added functionality isn't too important.]]

In your example, both _StateRun and _Just are prisms. The _Just prism focuses on the a part of a Maybe a whole. Such an a may or may not be present. If the Maybe a value is Just x for some x :: a, the part a is present and has value x, and that's what _Just focuses on. If the Maybe a value is Nothing, then the part a is not present, and _Just doesn't focus on anything.

It's somewhat similar for your prism _StateRun. If the whole StateStep is a StateRun x y value, then _StateRun focuses on that "part", represented as a tuple of the fields of the StateRun constructor, namely (x, y) :: (Int, Maybe Text). On the other hand, if the whole StateStep is a StatePause, that part isn't present, and the prism doesn't focus on anything.

When you compose prisms, like _StateRun and _Just, and lenses, like stStep and _2, you create a new optic that combines the composed series of focusing operations.

[[As was pointed out in the comments, this new optic isn't a prism; it's "only" traversal. In fact, it's a specific kind of traversal, called an "affine traversal". A run-of-the-mill traversal can focus on zero or more parts, while an affine traversal focuses on exactly zero (part not present) or one (unique part present). The lens library doesn't make the distinction between affine traversals and other sorts of traversals, though. The reason the new optic is "only" an affine traversal instead of a prism relates to that earlier technical point. Once you add lenses, you remove your ability to reconstruct the entire "whole" from a single "part". Again, if you're only using the optics for viewing, not setting, it won't really matter.]]

Anyway, consider the optic (affine traversal):

optic1 = stStep . _StateRun . _2 . _Just

This optic views a whole of type State. The first lens stStep focuses on its StateStep field. If that StateStep is a StateRun x (Just y) value, then the _StateRun prism focuses on the (x, Just y) part, while the _2 lens further focuses on the Just y part, and the _Just prism further focuses on the y :: Text part.

On the other hand, if the StateStep field is a StatePause, the optic optic1 doesn't focus on anything (because the second component prism _StateRun doesn't focus on anything), and if it's a StateRun x Nothing, the optic optic1 still doesn't focus on anything, because even though _StateRun can focus on (x, Nothing) and _2 can focus on Nothing, that final _Just doesn't focus on anything, so the whole optic fails to focus.

In particular, there's no danger that the lens _2 will "misfire" when processing a StatePause and try to reference a missing second field or anything like that. The fact that you've used _StateRun to focus on the tuple of fields of a StateRun constructor ensures that the desired field will be present if the whole optic focuses.

Now, here's why your second optic:

optic2 = stStep . _StateRun . _Just . stMMistake

doesn't work...

There are actually two problems. First, stStep . _StateRun takes a whole State and focuses on a part (Int, Maybe Text). This isn't a Maybe value, so it can't compose with the _Just prism yet. You want to select the Maybe Text field first, then apply the _Just prism, so what you actually want is something more like:

optic3 = stStep . _StateRun . stMMistake . _Just

This looks like it really should work, right? The stStep lens focuses on a StateStep, the _StateRun prism should focus only when a StateRun x y value is present, and the lens stMMistake ought to let you focus on the y :: Maybe Text, leaving the _Just to focus on the Text.

Unfortunately, this isn't how the prisms created with makePrisms work. The _StateRun prism focuses on a plain old tuple with unnamed fields, and those fields need to be further selected with _1, _2, etc., not stMMistake which is trying to select a named field.

In fact, if you take a careful look at stMMistake, you'll discover that -- all by itself -- it's an optic (an affine traversal, or as far as the lens library is concerned, just a traversal) that takes a whole StateStep and focuses on the _stMMistake field part directly, without having to specify the constructor. So, you can actually use stMMistake in place of _StateStepRun . _2, and the following should work identically:

mMistake = st ^? stStep . _StateStepRun . _2 . _Just
mMistake = st ^? stStep . stMMistake . _Just

This isn't some fundamental theoretical property of lenses or anything. It's just the naming and typing convention used by makeLenses and makePrisms. With makeLenses, you create optics that focus on named fields of data structures. If there's only one constructor:

data Foo = Bar { _x :: Int, _y :: Double }

or if there are multiple constructors but the field is present in all constructors:

data Foo = Bar { _x :: Int, _y :: Double }
         | Baz { _x :: Int, _z :: Char }

then the field optic (x in this example) is a lens that always focuses on that field. If there are multiple constructors and some have the field and some don't:

data Foo = Bar { _x :: Int, _y :: Double }
         | Baz { _x :: Int, _z :: Char }
         | Quux { _f :: Int -> Double }

then the field optic (x here) is an optic (traversal) that focuses on the field, but only when it's present (i.e., when the value is a Bar or a Baz but not when it's a Quux).

On the other hand makePrisms always creates constructor prisms that focus on the fields as unnamed tuples, and those fields will need to be referenced with _1, _2, etc., rather than any names those fields happen to have within that constructor.

Maybe that answers your question?

Wisniewski answered 1/3, 2022 at 2:48 Comment(3)
the type of stMMistake is Traversal. I guess working with that one instead of _StateStepRun . _2 is the most elegant wayCacophony
I think I should point out that when you compose a prism with a lens, you get a(n affine) traversal. A prism can be reversed to create a value, which means accessing something with a prism requires it gives you back everything it contained. This is why the generated prisms give you back a tuple of all the constructor's fields. When you project out of that tuple with a lens, you no longer can reverse the composed optic because you can't supply all the necessary data. All you have left is a traversal that will match zero or one things.Ninepins
Oops, you're both absolutely right. Thanks for the corrections. I've tried to update the answer.Wisniewski
R
3

Optics generally work out more cleanly when sum type constructors each have at most one field. In your case, you could write something like

data StateStep
    = StatePause
    | StateRun {-# UNPACK #-} !Runny

data Runny = Runny
  { _ryCounter :: Int
  , _ryNoMistake :: Maybe Text
  }

Using a strict field and (since that field is not "small" in the sense of -funpack-small-strict-fields) an {-# UNPACK #-} pragma, you can ensure that StateStep has the same runtime representation as in your code. But now you can get nice field lenses into Runny and everything will work out nicely—no magicked-up tuples.

Ruching answered 1/3, 2022 at 21:47 Comment(2)
looks promising, I will try it outCacophony
indeed the cleanest solutionCacophony

© 2022 - 2024 — McMap. All rights reserved.