Tips for writing monadic signatures
Consider the following example functions that add a random value to pure input:
addRand1 :: (MonadRandom m) => m (Int -> Int)
addRand2 :: (MonadRandom m) => Int -> m Int -- *can* be written as m (Int -> Int)
It is easy to convert addRand1
to a function with the same signature as addRand2
, but not vice versa .
For me, this provides strong evidence for what I should write addRand1
over addRand2
. This example addRand1
is of a truer / general type that usually captures important abstractions in Haskell.
While "correct" signature seems like an important aspect of functional programming, I also have many practical reasons why there addRand2
might be a better signature, even if it can be written with a addRand1
signature.
-
With interfaces:
class FakeMonadRandom m where getRandom :: (Random a, Num a) => m a getRandomR1 :: (Random a, Num a) => (a,a) -> m a getRandomR2 :: (Random a, Num a) => m ((a,a) -> a)
Suddenly
getRandomR1
it seems "more general" in the sense that it allows more instances (which are re-calledgetRandom
until the result is in a range, for example) compared togetRandomR2
which seems to need some sort of reduction technique. -
addRand2
easier to write / read:addRand1 :: (MonadRandom m) => m (Int -> Int) addRand1 = do x <- getRandom return (+x) -- in general requires `return $ \a -> ...` addRand2 :: (MonadRandom m) => Int -> m Int addRand2 a = (a+) <$> getRandom
-
addRand2
easier to use:foo :: (MonadRandom m) => m Int foo = do x <- addRand1 <*> (pure 3) -- ugly syntax f <- addRand1 -- or a two step process: sequence the function, then apply it x' <- addRand2 3 -- easy! return $ (f 3) + x + x'
-
addRand2
harder to use: considergetRandomR :: (MonadRandom m, Random a) => (a,a) -> m a
. For a given range, we can repeatedly choose and get different results, which is probably what we assume. However, if we havegetRandomR :: (MonadRandom m, Random a) => m ((a,a) -> a)
, we may be tempted to writedo f <- getRandomR return $ replicate 20 $ f (-10,10)
but will be very surprised by the result!
I really don't like how to write monadic code. "Version 2" seems to be better in many cases, but I recently came across an example where a "version 1" signature is required. *
What factors should influence my design decisions wrt monadic signatures? Is there a way to reconcile the seemingly conflicting goals of "common signatures" and "natural, clean, easy-to-use, strong syntax"?
*: I wrote a function foo :: a -> m b
that worked just fine (literally) for many years. When I tried to incorporate this into a new app (DSL with HOAS) I found I couldn't until I realized I foo
could rewrite it to have a signature m (a -> b)
. My new application suddenly appeared.
source to share
It depends on several factors:
- What signatures are actually possible (both here).
- What signatures are easy to use.
- Or more generally, if you want the most general interface, or generally the most general implementations.
The key to understanding the difference between Int -> m Int
and m (Int -> Int)
is that in the former case, the effect of ( m ...
) can depend on the input argument. For example, if m
- IO
, you might have a function that launches rockets n
, where n
is the function argument. On the other hand, the effect m (Int -> Int)
does not depend on anything - the effect does not "see" the argument of the returned function.
Getting back to your case: you take a clean input, generate a random number and add it to the input. We see that the effect (random number generation) is independent of the input. This is why we can have a signature m (Int -> Int)
. If the task was to generate random numbers n
, for example, the signature Int -> m [Int]
would work, but m (Int -> [Int])
it wouldn't.
As far as usability is concerned, Int -> m Int
it is more common in a monadic context because most monadic combinators expect a form signature a -> b -> ... -> m r
. For example, you usually write
getRandom >>= addRand2
or
addRand2 =<< getRandom
to add a random number to another random number.
Signatures such as m (Int -> Int)
are less common for monads, but are often used with applicative functors (each monad is also an applied functor) where effects cannot depend on parameters. In particular, the operator would work nicely: <*>
addRand1 <*> getRandom
In terms of generality, the signature affects the difficulty of using or implementing it. As you noticed, it addRand1
is more general from the point of view of the caller - he can always convert it to addRand2
if needed. On the other hand, it addRand2
is less general and therefore easier to implement. In your case, this really doesn't apply, but in some cases it may happen that it is possible to implement type signature m (Int -> Int)
, but not Int -> m Int
. This is reflected in the type hierarchy - monads are more specific than applicative functors, which means they give more energy to their user, but "harder" to implement - every monad is applicative, but not every applicative can be turned into a monad.
source to share
Easy to convert
addRand1
to a function with the same signature as addRand2, but not vice versa .
um.
-- | Adds a random value to its input
addRand2 :: MonadRandom m => Int -> m Int
addRand2 x = fmap (+x) getRand
-- | Returns a function which adds a (randomly chosen) fixed value to its input
addRand1 :: MonadRandom m => m (Int -> Int)
addRand1 = fmap (+) (addRand2 0)
How it works? Well, the challenge addRand1
is to get a randomly selected value and partially apply to it +
. Adding a random number to a dummy value is a great way to pick a random number!
I think you might be confused by the quantifiers in @chi's description . He did not say
For all monads
m
and typesa
andb
you cannot converta -> m b
tom (a -> b)
∀ m a b. ¬ ∃ f. f :: Monad m => (a -> m b) -> m (a -> b)
He said
You cannot convert
a -> m b
tom (a -> b)
for all monadsm
and typea
andb
.
¬ ∃ f. f :: ∀ m a b. Monad m => (a -> m b) -> m (a -> b)
Nothing prevents you from writing (a -> m b) -> m (a -> b)
for a particular monad m
and pair of types a
and b
.
source to share
Easy to convert
addRand1
to a function with the same signature asaddRand2
but not vice versa.
This is true, but remember that this "transformation" does not need to preserve the intended semantics.
I mean, if we have foo :: IO (Int -> ())
, we can write
bogusPrint :: Int -> IO ()
bogusPrint x = ($ x) <$> foo
but this will do the same I / O action for everyone x
! Hardly helpful.
Your argument seems like "I can define an object x :: A
or y :: B
something else . Well, I also know I can write f :: A->B
, so x :: A
is more general since y can allow y = f x :: B
". Personally, I think this is a great approach to the mind of your code! However, you need to check what y
is received as f x
is the inferred. Just because the types are the same does not mean that the value is correct.
So, in general, I think it depends on the monad. I would write both x
and y
(as Daniel Wagner suggests) and then check if it is indeed more general than the other - not only because the type is more general, but because the value y
can be (efficiently) reconstructed from x
.
source to share