Skip to content

Monoidal concatenation#327

Closed
serras wants to merge 3 commits intowell-typed:masterfrom
serras:master
Closed

Monoidal concatenation#327
serras wants to merge 3 commits intowell-typed:masterfrom
serras:master

Conversation

@serras
Copy link
Copy Markdown

@serras serras commented Jul 17, 2020

The idea is to generalize the summing operation in Data.Fold, so that it give stronger guarantees. For example, if you sum two traversals, you can get a traversal out of it.

I have also included an example of usage to return elements from a Tree on increasing level.

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 for the PR! As I understand it, the key point here is to generalise [i]summing so it can be used with traversals and setters, since they admit a monoid as well. We should certainly offer this in some form.

Maybe we should have less polymorphic versions that constrain the returned optic kind,e.g.

tsumming :: (Is k A_Traversal, Is l A_Traversal) => Optic' k is s a -> Optic' l js s a -> Traversal' s a

and perhaps we can even generalise this to Optic rather than Optic'? Though the accumulation of ...summing names does suggest a class like After might well be justified.

-- Essentially this collapses the hierarchy into
-- Traversal, Fold, and Setter,
-- and then takes the usual Join
type family AfterJoin (k :: OpticKind) (l :: OpticKind) where
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Perhaps I'm missing something, but is it the case that AfterJoin k l ~ Join A_Traversal (Join k l)? Couldn't we use that definition instead?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I haven't thought deeply about it, but I guess that would make the trick, since A_Traversal is the "bottom" of that restricted hierarchy.


infixr 6 <++>
class After is m where
nothing :: Optic' m is s a
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We already have ignored :: IxAffineTraversal i s s a b which is strictly more general than nothing, isn't it?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Not really, if only because of the types. nothing here can be a Setter, Fold, or Traversal, everything more general than ignored.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

AffineTraversals are (i.e. can be cast to) Setters, Folds and Traversals.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Right, I mean ignored is more general in terms of the Is relation. It doesn't strictly have a more general type, but optics-style polymorphism will allow it to be used with eliminators for setters, folds and traverals.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

castOptic ignored (and noIx (castOptic ignored) when explicit unindexed variant is needed) do the job.

nothing :: Optic' m is s a
(<++>) :: (Is k m, Is l m, m ~ AfterJoin k l)
=> Optic' k is s a -> Optic' l is s a
-> Optic' m is s a
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

In general, we tend to avoid introducing new operators in optics, or at least provide a non-operator equivalent. Is there precedent for this operator elsewhere? I suppose summing would be the obvious name choice if we don't mind a slightly breaking change.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I didn't know about that rule. I haven't seen this operator used anywhere else, but I think that summing would work great here (and would provide a good backwards-compatibility story for those already using summing in Fold).


instance After NoIx A_Traversal where
nothing = traversalVL $ \_ s -> pure s
t1 <++> t2 = traversalVL $ \f s -> traverseOf t1 f s *> traverseOf t2 f s
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I wonder if there is any benefit to implementing this directly on the profunctor representation rather than going via the VL representation...


instance After NoIx A_Traversal where
nothing = traversalVL $ \_ s -> pure s
t1 <++> t2 = traversalVL $ \f s -> traverseOf t1 f s *> traverseOf t2 f s
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The *> is suspicious. What Setter made from this traversal does. (I suspect nothing to the first half).

Yet, the After NoIx A_Setter instance does something else.

This feels like a giving shotgun to a kid.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Right, I see that there is a discrepancy between what the instances for Setter and Traversal do.


instance After NoIx A_Setter where
nothing = sets (\_ s -> s)
s1 <++> s2 = sets (\f s -> over s2 f (over s1 f s))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

There should be a big warning. The resulting Setter is lawful only(?) if halves of it don't modify the same parts of underlying structure.

I think that in this case, <++> have to be commutative operator. Yet for folds that doesn't make sense.

This structure feels very ad-hoc to me. I'm not sure this is good idea at all to overload this (or summing or ...) name.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

That is exactly the use case I had in mind. Imagine that I want to define several Traversals which works over binary trees. With this API I could define it as:

data BinaryTree a = Leaf { _leaf :: a } | Node { _left, _right :: BinaryTree a }

preOrder = leaf <++> left % preOrder <++> right % preOrder
inOrder = left % preOrder <++> leaf <++> right % preOrder
postOrder = left % preOrder <++> right % preOrder <++> leaf

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is a nice example use case, and could do with appearing in the documentation, whatever API variant we end up with. 😄

Copy link
Copy Markdown
Contributor

@phadej phadej left a comment

Choose a reason for hiding this comment

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

Let me highlight the disrepacy of traversal to setter.

Prelude Optics Optics.Monoidal Data.Char> data BinaryTree a = Leaf { _leaf :: a } | Node { _left, _right :: BinaryTree a } deriving Show
Prelude Optics Optics.Monoidal Data.Char> makeLenses ''BinaryTree;
Prelude Optics Optics.Monoidal Data.Char> let preOrder :: Traversal' (BinaryTree a) a; preOrder = leaf <++> (left % preOrder) <++> (right % preOrder)
-- traversal as fold, works
Prelude Optics Optics.Monoidal Data.Char> toListOf preOrder $ Node (Leaf 'x') (Node (Leaf 'y') (Leaf 'z'))
"xyz"

-- traversal as setter, doesn't work
Prelude Optics Optics.Monoidal Data.Char> over preOrder toUpper $ Node (Leaf 'x') (Node (Leaf 'y') (Leaf 'z'))
Node {_left = Leaf {_leaf = 'x'}, _right = Node {_left = Leaf {_leaf = 'y'}, _right = Leaf {_leaf = 'z'}}}

-- traversal as traversal is not lawful.
Prelude Optics Optics.Monoidal Data.Char> let preOrderSetter :: Setter' (BinaryTree a) a; preOrderSetter = leaf <++> (left % preOrderSetter) <++> (right % preOrderSetter)

-- expected
Prelude Optics Optics.Monoidal Data.Char> toListOf preOrderSetter $ Node (Leaf 'x') (Node (Leaf 'y') (Leaf 'z'))

<interactive>:45:1: error:
     A_Setter cannot be used as A_Fold
      Perhaps you meant one of these:

-- as setter, works
Prelude Optics Optics.Monoidal Data.Char> over preOrderSetter toUpper $ Node (Leaf 'x') (Node (Leaf 'y') (Leaf 'z'))
Node {_left = Leaf {_leaf = 'X'}, _right = Node {_left = Leaf {_leaf = 'Y'}, _right = Leaf {_leaf = 'Z'}}}

Current summing would get you Fold behaviou
r already.
To compose traversals, you need to do so something like adjoin
in https://hackage.haskell.org/package/lens-4.19.2/docs/Control-Lens-Unsound.html

FWIW: I don't like Unsound name. There's nothing "unsound" as such,
just that the preconditions are very easy to violate (c.f. lens constructor/introduction has preconditions too).

So I would rename <++> to adjoin, implement it as in lens (so Traversal degenerates to proper Fold and Setters). Add a warning sign, and not export by default.

  • Adam's comment, I do agree with them.


infixr 6 <++>
class After is m where
nothing :: Optic' m is s a
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

AffineTraversals are (i.e. can be cast to) Setters, Folds and Traversals.

@adamgundry
Copy link
Copy Markdown
Member

@phadej thanks for catching the Traversal/Setter difference and the unlawfulness. I buy that adjoin is potentially unlawful for non-disjoint Traversals (and I agree with your comments wrt "unsound" being too strong). But is there an example of breaking the laws for Setter alone? I can't immediately see one, because Setter doesn't give you a way to observe how many times the update function is applied.

@phadej
Copy link
Copy Markdown
Contributor

phadej commented Jul 20, 2020

@adamgundry you can break over setter f . over setter g = over setter (f . g) law quite easily.

Prelude Optics Optics.Monoidal Data.Char> let setter = castOptic @A_Setter simple <++> castOptic @A_Setter simple
Prelude Optics Optics.Monoidal Data.Char> over setter (+1) . over setter (*2) $ (3 :: Int)
14
Prelude Optics Optics.Monoidal Data.Char> over setter ((+1) . (*2)) $ (3 :: Int)
15

@arybczak
Copy link
Copy Markdown
Collaborator

arybczak commented Jul 20, 2020

This is similar to what I tried to do in #91 (comment)

Combination of results with read-only optics works out because you can discard the structure you traverse, so *> does the job fine, but the setter part needs to preserve the structure of traversed data and I suspect it cannot be done in general, so it simply won't work for read-write optics.

What incrLevels does when used as a traversal? I suspect it doesn't work as advertised.

@serras
Copy link
Copy Markdown
Author

serras commented Jul 21, 2020

I have just pushed a new attempt, which satisfies the traversal laws if the traversals focus on different parts of the value.

*Optics Optics.Monoidal Data.Char> data BinaryTree a = Leaf { _leaf :: a } | Node { _left, _right :: BinaryTree a } deriving Show
*Optics Optics.Monoidal Data.Char> makeLenses ''BinaryTree;
*Optics Optics.Monoidal Data.Char> let preOrder :: Traversal' (BinaryTree a) a; preOrder = leaf <++> (left % preOrder) <++> (right % preOrder)
*Optics Optics.Monoidal Data.Char> toListOf preOrder $ Node (Leaf 'x') (Node (Leaf 'y') (Leaf 'z'))
"xyz"
*Optics Optics.Monoidal Data.Char> over preOrder toUpper $ Node (Leaf 'x') (Node (Leaf 'y') (Leaf 'z'))
Node {_left = Leaf {_leaf = 'X'}, _right = Node {_left = Leaf {_leaf = 'Y'}, _right = Leaf {_leaf = 'Z'}}}

@serras
Copy link
Copy Markdown
Author

serras commented Jul 21, 2020

Note that my end goal is also to provide a generalized interface to failing, which does not only work on Fold. So for example you could compose two affine traversals and that would focus on the first one that matches.

@arybczak
Copy link
Copy Markdown
Collaborator

arybczak commented Jul 27, 2020

I'm not convinced that introducing a type class for this is a good idea. The only non-trivial instances currently are for Traversal and Setter, both of which come with a set of caveats. In addition, pure setters are somewhat rare.

Since split and After instance for Traversal are already in lens under lensProduct and adjoin, I'd be inclined to include them under these names in optics and avoid unnecessary api differences.

As a side note, I'm curious how well adjoin optimizes... @serras perhaps you could add an inspection test to Optics.Tests.Misc that at least profunctor classes optimize away?

@arybczak
Copy link
Copy Markdown
Collaborator

arybczak commented Jul 27, 2020

After some experimentation I got this. Seems to optimize very well (existing version that calls partsOf also optimizes well for small examples, but this one has much less code after inlining takes place so should compile faster and potentially optimize better for non-trivial cases).

adjoin
  :: (Is k A_Traversal, Is l A_Traversal)
  => Optic' k is s a
  -> Optic' l js s a
  -> Traversal' s a
adjoin o1 o2 = combined % traversed
  where
    combined = lensVL $ \f s -> evalState (    traverseOf o1 update s
                                           >>= traverseOf o2 update)
      <$> f (   toListOf (castOptic @A_Traversal o1) s
             ++ toListOf (castOptic @A_Traversal o2) s)

    update a = get >>= \case
      a' : as' -> put as' >> pure a'
      []       ->            pure a
{-# INLINE adjoin #-}

-- indexed variant
iadjoin
  :: (Is k A_Traversal, Is l A_Traversal, is `HasSingleIndex` i)
  => Optic' k is s a
  -> Optic' l is s a
  -> IxTraversal' i s a
iadjoin o1 o2 = conjoined (adjoin o1 o2) (combined % traversed % itraversed)
  where
    combined = lensVL $ \f s -> evalState (    traverseOf o1 update s
                                           >>= traverseOf o2 update)
      <$> f (   itoListOf (castOptic @A_Traversal o1) s
             ++ itoListOf (castOptic @A_Traversal o2) s)

    update a = get >>= \case
      (_, a') : as' -> put as' >> pure a'
      []            ->            pure a
{-# INLINE iadjoin #-}

@arybczak
Copy link
Copy Markdown
Collaborator

One more argument against the type class: things like _1 <++> _2 don't type check, whereas adjoin _1 _2 does, because adjoin accepts anything that's a traversal, whereas <++> accepts a pair of traversals or a pair of folds etc.

That's the reason we have summing and asumming instead of having summing as a class method.

@adamgundry
Copy link
Copy Markdown
Member

@arybczak thanks for looking at this. I'd be happy with lensProduct and adjoin for lens compatibility. I don't have a strong sense of whether it is worth offering a version for Setter.

We should have "Monoid structure" sections of the Optics.[Ix](Setter|Traversal) docs, and perhaps an Optics.Monoidal module that documents all these monoids, whether or not it actually exposes them as a type class.

@serras
Copy link
Copy Markdown
Author

serras commented Jul 29, 2020

Given this discussion, I am going to close the PR, since clearly is should not be merged as is. Let me know if I could help bringing the other operators to optics, as I find them quite useful, even though they might not be "safe" unless you point to different parts of a value.

@serras serras closed this Jul 29, 2020
This was referenced Jul 31, 2020
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.

4 participants