Skip to content

Instantly share code, notes, and snippets.

@DarinM223
Last active March 15, 2022 18:04
Show Gist options
  • Select an option

  • Save DarinM223/f0fe5e63133f46d99e9543bac610e2dd to your computer and use it in GitHub Desktop.

Select an option

Save DarinM223/f0fe5e63133f46d99e9543bac610e2dd to your computer and use it in GitHub Desktop.
ImplicitParams Notes

ImplicitParams notes:

Give explicit type signatures to all functions that use implicits

All top level functions should already have explicit type signatures, and there are GHC warnings that force this rule.

However, all local functions that use implicits should also have type signatures. Why? Because these functions are most likely going to be confusing to understand without a type signature.

For example:

doSomething :: IO ()
doSomething = do
  let ?a = 1
  let f a = a + ?a -- What is the type of f?
  return ()

f could be (?a :: Int) => Int -> Int or Int -> Int. GHC will automatically infer f as (?a :: Int) => Int -> Int but this is confusing to read without the explicit type signature:

doSomething :: IO ()
doSomething = do
  let ?a = 1
  let
    f :: (?a :: Int) => Int -> Int
    f a = a + ?a
  return ()

These functions without type signatures are not only confusing for other people to read, they can also be confusing for the compiler to read. If you don't add type annotations to functions that use implicits, these functions may give you wrong behavior. Here's an example from the GHC manual:

len1 :: [a] -> Int
len1 xs = let ?acc = 0 in len_acc1 xs

len_acc1 [] = ?acc
len_acc1 (x:xs) = let ?acc = ?acc + (1::Int) in len_acc1 xs

len2 :: [a] -> Int
len2 xs = let ?acc = 0 in len_acc2 xs

len_acc2 :: (?acc :: Int) => [a] -> Int
len_acc2 [] = ?acc
len_acc2 (x:xs) = let ?acc = ?acc + (1::Int) in len_acc2 xs
> len1 "hello"
0
> len2 "hello"
5

The only difference between len1 and len2 is that len_acc1 doesn't have a type signature, but this is enough to make len1 give the wrong result.

Does this problem also happen with lambdas? Not really. The above problem happens with recursion, and it isn't possible to do direct recursion with lambdas. Even if we try something that doesn't use direct recursion, lambdas still give the proper result:

y :: (?a :: Int)
  => ((?a :: Int) => ((?a :: Int) => t1 -> t2) -> t1 -> t2)
  -> t1
  -> t2
y f = f (y f)

blah :: Int -> Int
blah =
  let ?a = 0 in y (\t n -> if n == 0 then ?a else let ?a = ?a + 1 in t (n - 1))

blah' :: Int -> Int
blah' = let ?a = 0 in y f
 where
  f t n = if n == 0 then ?a else let ?a = ?a + 1 in t (n - 1)
> blah 5
5
> blah' 5
0

The function that took the lambda gave the correct behavior, unlike the function that took in another function. So if all your functions that use implicits have the proper type annotations, there shouldn't be any problems with implicits in lambdas.

Don't use braces in let expressions

Instead of doing this in a do block:

let { ?a = 1; ?b = 2; ?c = 3 } in
    doSomething

it is better to do this:

let ?a = 1; ?b = 2; ?c = 3 in
    doSomething

Why? Because with the second version, you can tell that the let is a let ... in expression by the indentation of doSomething. With the braces, the compiler actually tolerates the different indentation:

let { ?a = 1; ?b = 2; ?c = 3 };
    doSomething

This compiles and binds the implicits for the whole scope instead of only inside doSomething, which is probably not what you were intending to do here. In contrast:

let ?a = 1; ?b = 2; ?c = 3;
    doSomething

This causes an 'Unexpected do block' compile error, which is good because it means this indentation only works for let ... in expressions.

Use ConstraintKinds to manage boilerplate

One of the complaints with ImplicitParams is that you have to manually write the implicits in the type signature. The verbosity with this can be reduced with ConstraintKinds:

type Ctx =
  ( ?a :: Int
  , ?b :: Int
  , ?c :: Int
  , ?d :: Int
  )

foo1 :: Ctx => Int -> Int
foo1 = undefined

foo2 :: Ctx => (Ctx => Int -> Int) -> Int
foo2 = undefined

You can even combine implicits:

newtype A m = A { runA :: forall a. a -> m a }
newtype B m = B { runB :: forall b. b -> m b }

type Ctx1 m =
  ( ?a :: A m
  , ?b :: B m
  )

type Ctx2 =
  ( ?c :: Int
  , ?d :: Int
  )

type Ctx m = (Ctx1 m, Ctx2)

foo :: Functor f => Ctx f => f Int
foo = (?c + ?d +) <$> runA ?a 2

Releasing implicits in a constrained scope

You may want to run functions that depend on implicits inside a constrained scope, but not expose the implicits outside that scope. To do this you can store the values to be released in the scope inside a record. Then you can run a function like this:

data CtxData k v m = CtxData
  { ctxDb      :: Db m
  , ctxLogger  :: Logger m
  , ctxKVStore :: Store k v m
  }

type Ctx k v m = (?db :: Db m, ?logger :: Logger m, ?kvStore :: Store k v m)

withCtxData :: CtxData k v m -> (Ctx k v m => m a) -> m a
withCtxData CtxData{..} f =
  let ?db = ctxDb; ?logger = ctxLogger; ?kvStore = ctxKVStore in f

doSomething :: CtxData k v m -> m ()
doSomething ctx = withCtxData ctx $ do
  r <- logAndQuery "SELECT * FROM users;"
  log ?logger $ show r

withCtxData acts like a runTransformerT function for mtl style typeclasses.

Pros/cons of ImplicitParams

The main alternative to ImplicitParams is Has typeclasses and lenses.

Advantages of ImplicitParams:

  • No problems with duplicate record fields
  • Works with all GHC versions that are still being used
  • No need to import a lens library (lens, microlens, optics) and either generate lenses with TemplateHaskell or Generics (both of which have slow compile times) or manually write Has typeclass instances for every field.
  • Clear error messages
  • Better ergonomics than Has typeclasses and ReaderT:
doSomething :: ( Monad m
               , HasField "_logger" ctx (Logger m)
               , HasField "_db" ctx (Db m) )
            => Logger m -> ReaderT ctx m ()
doSomething logger = do
  logger' <- view $ field @"_logger"
  db <- view $ field @"_db"
  lift $ log logger' "Hello world!"
  doSomething'
  r <- lift $ logAndRunQuery logger' db "SELECT * FROM users;"
  lift $ log logger $ show r

compared to:

doSomething :: Monad m
            => (?logger :: Logger m, ?db :: Db m)
            => Logger m -> m ()
doSomething logger = do
  log ?logger "Hello world!"
  doSomething'
  r <- logAndRunQuery ?logger ?db "SELECT * FROM users;"
  log logger $ show r

Notice that the implicit version doesn't need lifts and doesn't need extra lines to retrieve each record that is needed. Also implicit variable names cannot conflict with existing parameters and normal variable names.

Advantages of Has typeclasses:

  • No weird behavior with automatic function type inference. If GHC can't infer a typeclass, then the compilation fails instead of silently doing something wrong.
  • More idealogically pure; typeclasses were supposed to be the main way to handle implicits.
  • RecordDotSyntax will make basic record getting and setting simpler with less language extensions. Of course, RecordDotSyntax also has its own problems but most of these will trigger compile errors instead of runtime bugs.
  • ImplicitParams adds another behavior to let that shadows implicits. This shouldn't be surprising to people with experience in other functional languages, but some will consider extra behavior on a common syntax to be wrong.
  • ImplicitParams is slower performance wise than using Has typeclasses for certain benchmarks.

How dangerous is ImplicitParams?

ImplicitParams is a bit dangerous, but in practice it isn't very likely that the problems shown above will happen. If you are doing something confusing, then it is simple to add a type annotation to that function. If you run into a bug with implicits, it is easy to fix: add type annotations to the local functions that use implicits. So pretty much searching the function with your editor for the question mark. Normal variable shadowing (which is what you get if you don't enable -Wall) is much, much more dangerous than ImplicitParams because when you encounter a bug, it may take hours of eyeballing the code to find the cause.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment