Haskell: how to create the most generic function possible that applies a function to the tuple elements


This is a personal exercise to understand the limits of Haskell's type system a little better. I want to create the most generic function I can that applies some function to each entry in a 2 entry tuple eg:

applyToTuple fn (a,b) = (fn a, fn b)

I am trying to make this function work in each of the following cases:

(1) applyToTuple length ([1,2,3] "hello")
(2) applyToTuple show ((2 :: Double), 'c')
(3) applyToTuple (+5) (10 :: Int, 2.3 :: Float)

So for length the items in the pair must be Foldable, for show they must be instances of Show etc.

Using RankNTypes I can go some of the way, for example:

{-# LANGUAGE RankNTypes #-}
applyToTupleFixed :: (forall t1. f t1 -> c) -> (f a, f b) -> (c, c)
applyToTupleFixed fn (a,b) = (fn a, fn b)

This allows a function that can work on a general context f to be applied to items in that context. (1) works with this, but the tuple items in (2) and (3) have no context and so they don't work (and anyway, 3 would return different types). I could of course define a context to place items in eg:

data Sh a = Show a => Sh a
instance Show (Sh a) where show (Sh a) = show a

applyToTuple show (Sh (2 :: Double), Sh 'c')

to get other examples working. I am just wondering whether it is possible to define such a generic function in Haskell without having to wrap the items in the tuples or give applyToTuple a more specific type signature.

You were pretty close with the last one, but you need to add constraints:

{-# LANGUAGE RankNTypes      #-}
{-# LANGUAGE ConstraintKinds #-}
import Data.Proxy

both :: (c a, c b)
     => Proxy c
        -> (forall x. c x => x -> r)
        -> (a, b)
        -> (r, r)
both Proxy f (x, y) = (f x, f y)

demo :: (String, String)
demo = both (Proxy :: Proxy Show) show ('a', True)

The Proxy is necessary to pass the ambiguity check. I think this is because it wouldn't otherwise know which part of the constraint to use from the function.

In order to unify this with other cases, you need to allow empty constraints. It might be possible, but I'm not sure. You can't partially apply type families, which might make it a bit trickier.

This is a bit more flexible than I thought it would be though:

demo2 :: (Char, Char)
demo2 = both (Proxy :: Proxy ((~) Char)) id ('a', 'b')

I had no idea you could partially apply type equality until this moment, haha.

Unfortunately, this doesn't work:

demo3 :: (Int, Int)
demo3 = both (Proxy :: Proxy ((~) [a])) length ([1,2,3::Int], "hello")

For the particular case of lists though, we can use IsList from GHC.Exts to get this to work (IsList is usually used with the OverloadedLists extension, but we don't need that here):

demo3 :: (Int, Int)
demo3 = both (Proxy :: Proxy IsList) (length . toList) ([1,2,3], "hello")

Of course, the simplest (and even more general) solution is to use a function of type (a -> a') -> (b -> b') -> (a, b) -> (a', b') (like bimap from Data.Bifunctor or (***) from Control.Arrow) and just give it the same function twice:

λ> bimap length length ([1,2,3], "hello")

Unifying all three examples from the question

Okay, after some more thought and coding, I figured out how to at least unify the three examples you gave into a single function. It's not the most intuitive thing maybe, but it seems to work. The trick is that, in addition to what we have above, we allow the function to give back two different result types (the elements of the resulting pair can be of different types) if we give the type system the following restriction:

Both result types must have a relation to the corresponding input type given by a two-parameter type class (we can look at a one parameter type class as a logical predicate on a type and we can look at a two parameter type class as capturing a binary relation between two types).

This is necessary for something like applyToTuple (+5) (10 :: Int, 2.3 :: Float), since it gives you back (Int, Float).

With this, we get:

{-# LANGUAGE RankNTypes            #-}
{-# LANGUAGE ConstraintKinds       #-}
{-# LANGUAGE FlexibleInstances     #-}
{-# LANGUAGE MultiParamTypeClasses #-}
import Data.Proxy

import GHC.Exts

both :: (c a, c b
        ,p a r1  -- p is a relation between a and r1
        ,p b r2  -- and also a relation between b and r2
     => Proxy c
        -> Proxy p
        -> (forall r x. (c x, p x r) => x -> r) -- An input type x and a corresponding
                                                -- result type r are valid iff the p from
                                                -- before is a relation between x and r,
                                                -- where x is an instance of c
        -> (a, b)
        -> (r1, r2)
both Proxy Proxy f (x, y) = (f x, f y)

Proxy p represents our relation between the input and output types. Next, we define a convenience class (which, as far as I know, doesn't exist anywhere already):

class r ~ a => Constant a b r
instance Constant a b a      -- We restrict the first and the third type argument to
                             -- be the same

This lets us use both when the result type stays the same by partially applying Constant to the type we know it will be (I also didn't know you could partially apply type classes until now. I'm learning a lot for this answer, haha). For example, if we know that it will be Int in both results:

example1 :: (Int, Int)
example1 =
  both (Proxy :: Proxy IsList)         -- The argument must be an IsList instance
       (Proxy :: Proxy (Constant Int)) -- The result type must be Int
       (length . toList)
       ([1,2,3], "hello")

Likewise for your second test case:

example2 :: (String, String)
example2 =
  both (Proxy :: Proxy Show)              -- The argument must be a Show instance
       (Proxy :: Proxy (Constant String)) -- The result type must be String
       ('a', True)

The third one is where it gets a bit more interesting:

example3 :: (Int, Float)
example3 =
  both (Proxy :: Proxy Num)  -- Constrain the the argument to be a Num instance
       (Proxy :: Proxy (~))  -- <- Tell the type system that the result type of
                             --    (+5) is the same as the argument type.
       (10 :: Int, 2.3 :: Float)

Our relation between input and output type here is actually only slightly more complex than the other two examples: instead of ignoring the first type in the relation, we say that the input and output types must be the same (which works since (+5) :: Num a => a -> a). In other words, in this particular case, our relation is the equality relation.