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-called getRandom

    until the result is in a range, for example) compared to getRandomR2

    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: consider getRandomR :: (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 have getRandomR :: (MonadRandom m, Random a) => m ((a,a) -> a)

    , we may be tempted to write

    do
      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.

+3


source to share


4 answers


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.

+4


source


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 types a

and b

you cannot convert a -> 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

to m (a -> b)

for all monads m

and type a

and b

.

¬ ∃ 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

.

+2


source


Why not both?

Or, in English: why not both? It is rare that both signatures are possible, but when they are, each version can be useful in different contexts.

+1


source


Easy to convert addRand1

to a function with the same signature as addRand2

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

.

0


source







All Articles