3

This Reddit post by Edward Kmett provides a constructive definition of a natural map, the one from the free theorem for fmap (which I read in yet another Edward Kmett's post):

For given f, g, h and k, such that f . g = h . k: $map f . fmap g = fmap h . $map k, where $map is the natural map for the given constructor.

I do not fully understand the algorithm. Let us approach it step-by-step:

We can define a "natural map" by induction over any particular concrete choice of F you give. Ultimately any such ADT is made out of sums, products, (->)'s, 1s, 0s, a's, invocations of other functors, etc.

Consider:

data Smth a = A a a a | B a (Maybe a) | U | Z Void deriving ...

No arrows. Let us see how fmap (which I reckon to be the natural choice for any ADT without (->)s in it) would operate here:

instance Functor Smth where
  fmap xy (A x x1 x2)  = A (xy x) (xy x1) (xy x2)
  fmap xy (B x xPlus1) = B (xy x) (fmap xy xPlus1) 
  -- One can pattern-match 'xPlus1' as 'Just x1' and 'Nothing'.
  -- From my point of view, 'fmap' suits better here. Reasons below.
  fmap _xy U     = U 
  fmap _xy (Z z) = absurd z

Which seems natural. To put this more formally, we apply xy to every x, apply fmap xy to every T x, where T is a Functor, we leave every unit unchanged, and we pass every Void onto absurd. This works for recursive definitions too!

data Lst a = Unit | Prepend a (Lst a) deriving ...

instance Functor Lst where
    fmap xy Unit             = Unit
    fmap xy (Prepend x lstX) = Prepend (xy x) (fmap xy lstX)

And for the non-inductive types:(Detailed explanation in this answer under the linked post.)

Graph a = Node a [Graph a]

instance Functor Graph where
    fmap xy (Node x children) = Node (xy x) (fmap (fmap xy) children) 

This part is clear.

When we allow (->) we now have the first thing that mixes variance up. The left-hand type argument of (->) is in contravariant position, the right-hand side is in covariant position. So you need to track the final type variable through the entire ADT and see if it occurs in positive and/or negative position.

Now we include (->)s. Let us try to keep this induction going:

We somehow derived natural maps for T a and S a. Thus, we want to consider the following:

data T2S a = T2S (T a -> S a)

instance ?Class? T2S where
    ?map? ?? (T2S tx2sx) = T2S $ \ ty -> ???

And I believe this to be the point where we start choosing. We have the following options:

  • (Phantom) a occurs neither in T nor in S. a in T2S is phantom, thus, we can implement both fmap and contramap as const phantom.
  • (Covariant) a occurs in S a and does not occur in T a. Thus, this something among the lines of ReaderT with S a (which does not actually depend on a) as environment, which substitutes ?Class? with Functor, ?map? with fmap, ???, ?? with xy with:
    let tx = phantom ty 
        sx = tx2sx tx
        sy = fmap xy sx
    in sy
    
  • (Contravariant) a occurs in T a and does not occur in S a. I do not see a way to make this thing a covariant functor, so we implement a Contravariant instance here, which substitutes ?map? with contramap, ?? with yx, ??? with:
    let tx = fmap yx ty 
        sx = tx2sx tx
        sy = phantom sx 
    in sy
    
  • (Invariant) a occurs both in T a and S a. We can no longer use phantom, which came in quite handy. There is a module Data.Functor.Invariant by Edward Kmett. It provides the following class with a map:
    class Invariant f where
      invmap :: (a -> b) -> (b -> a) -> f a -> f b
      -- and some generic stuff...
    
    And yet, I cannot see a way to turn this into something we can pluf into the free theorem for fmap - the type requires an additional function-argument, which we can't brush off as id or something. Anyway, we put invmap instead of ?map?, xy yx instead of ??, and the following instead of ???:
    let tx = fmap yx ty 
        sx = tx2sx tx
        sy = fmap xy sx
    in sy
    

So, is my understanding of such an algorithm correct? If so, how are we to properly process the Invariant case?

Zhiltsoff Igor
  • 1,812
  • 8
  • 24
  • To make `T a -> S a` into a (covariant) functor so to define `fmap`, you essentially need `T` to be a `Contravariant` and `S` to be a (covariant) `Functor`. Then you get something like `fmap (f :: a->b) (myFunction :: T a -> S a) = fmap f . myFunction . contramap f :: T b -> S b`. – chi Apr 29 '21 at 11:37
  • @chi so there are several options within the **Invariant** case? Accordingly, phantom functors are both covariant and contravariant, a feature we use for **Covariant** and **Contravariant** cases. — Thus, we can actually derive `Functor` from the **Invariant** case. – Zhiltsoff Igor Apr 29 '21 at 11:45
  • @ZhiltsoffIgor Well, I think chi is saying that your `invariant` case is incorrectly named. You seem to be consistently assuming that "`a` appears in `t`" actually means "`a` appears covariantly in `t`", which is an incorrect assumption. The type `S a -> T a` could have `a` appearing in both `S a` and `T a` and still be any of covariant, contravariant, or invariant. – Daniel Wagner Apr 29 '21 at 15:21
  • @DanielWagner aha, so, there are 16 cases, instead of 4? – Zhiltsoff Igor Apr 29 '21 at 15:35
  • @DanielWagner Hence Edward Kmett's intuition about the negative/positive positions. If `a` is in negative position in `T a` and phantom in `S a`, it is in negative *times* negative, that is positive, position in `T a -> S a`, and positive *times* negative, that is negative, position in `S a -> T a`. Am I explaining that right? Indeed, `data T a = T ((a -> Int) -> Int) deriving Functor` works. – Zhiltsoff Igor Apr 29 '21 at 15:41
  • @ZhiltsoffIgor Well, I think there's just 1 case instead of 4, see my answer below. ^_^ But yes, the rest of your explanation sounds about right to me. – Daniel Wagner Apr 29 '21 at 16:28

1 Answers1

4

I think your algorithm is too complex, because you are trying to write one algorithm. Writing two algorithms instead makes things much simpler. One algorithm will build the natural fmap, and the other will build the natural contramap. BUT both algorithms need to be nondeterministic in the following sense: there will be types where they cannot succeed, and so do not return an implementation; and there will be types where there are multiple ways they can succeed, but they're all equivalent.

To start, let's carefully define what it means to be a parameterized type. Here's the different kinds of parameterized types we can have:

F ::= F + F'
    | F * F'
    | F -> F'
    | F . F'
    | Id
    | Const X

In Const X, the X ranges over all concrete, non-parameterized types, like Int and Bool and so forth. And here's their interpretation, i.e. the concrete type they are isomorphic to once given a parameter:

[[F + F']] a = Either ([[F]] a) ([[F']] a)
[[F * F']] a = ([[F]] a, [[F']] a)
[[F -> F']] a = [[F]] a -> [[F']] a
[[F . F']] a = [[F]] ([[F']] a)
[[Id]] a = a
[[Const X]] a = X

Now we can give our two algorithms. The first bit you've already written yourself:

fmap @(F + F') f (Left x) = Left (fmap @F f x)
fmap @(F + F') f (Right x) = Right (fmap @F' f x)
fmap @(F * F') f (x, y) = (fmap @F f x, fmap @F f y)
fmap @(Id) f x = f x
fmap @(Const X) f x = x

These correspond to the clauses you gave in your first instance. Then, in your [Graph a] example, you gave a clause corresponding to this:

fmap @(F . F') f x = fmap @F (fmap @F' f) x

That's fine, but this is also the first moment where we get some nondeterminism. One way to make this a functor is indeed nested fmaps; but another way is nested contramaps.

fmap @(F . F') f x = contramap @F (contramap @F' f) x

If both clauses are possible, then there are no Ids in either F or F', so both instances will return x unchanged.

The only thing left now is the arrow case, the one you ask about. But it turns out it's very easy in this formalism, there is only one choice:

fmap @(F -> F') f x = fmap @F' f . x . contramap @F f

That's the whole algorithm, in full detail, for defining the natural fmap. ...except one detail, which is the algorithm for the natural contramap. But hopefully if you followed all of the above, you can reproduce that algorithm yourself. I encourage you to give it a shot, then check your answers against mine below.

contramap @(F + F') f (Left x) = Left (contramap @F f x)
contramap @(F + F') f (Right x) = Right (contramap @F' f x)
contramap @(F * F') f (x, y) = (contramap @F f x, contramap @F' f y)
contramap @(F -> F') f x = contramap @F' f . x . fmap @F f

contramap @(F . F') f x = contramap @F (fmap @F' f) x
-- OR
contramap @(F . F') f x = fmap @F (contramap @F' f) x

-- contramap @(Id) fails
contramap @(Const X) f x = x

One thing of interest to me personally: it turns out that contramap @(Id) is the only leaf case that fails. All further failures are inductive failures ultimately deriving from this one -- a fact I had never thought of before! (The dual statement is that it turns out that fmap @(Id) is the only leaf case that actually uses its first function argument.)

Daniel Wagner
  • 145,880
  • 9
  • 220
  • 380
  • 1
    I surely took my time to respond :P. Will assign a small bounty as interest. Anyway, why do you omit the **third** algorithm for `invmap`? Every `Functor` and `Contravariant` can be made into a `Invariant` (`invmap ab _ba x = fmap ab x`) but not the other way around. Thus, all these cases would fail for, say, `Endo a = a -> a`. I wrote down this algorithm myself. The only trouble is that I got **7** subcases in `F -> F'` case... May I add my algorithm as an edit to your post, so you can decline it, if I am wrong, or accept it, if I am right? – Zhiltsoff Igor Jul 24 '21 at 07:16
  • @ZhiltsoffIgor No deep reason I left it out; just that it's less often used. Or, to fire back: why didn't you ask about the missing algorithm for `Phantom` (which could as well be called `Equivariant`)? ;-) I think teaching this way of thinking -- of defining what building blocks your types are made of and then writing a simple inductive function -- is more important than the details of the actual inductive function built at the end using this technique. Even in the answer already written I debated not explicitly writing out the `contramap` definition. – Daniel Wagner Jul 24 '21 at 14:50
  • well, a phantom type is, as you said under the other post we’ve met under, `Const` in disguise. And we have already derived both `fmap` and `contramap` for `Const`, which can be combined into `phantom`: `() <$ x $< ()`. I guess, that’s what we do when both `fmap` and `contramap` (and `invmap`) are derived. – Zhiltsoff Igor Jul 24 '21 at 16:31
  • 1
    [A follow-up question](https://stackoverflow.com/questions/68566749/substituting-the-derivation-of-fmap-and-contramap-with-invmap) – Zhiltsoff Igor Jul 28 '21 at 20:06