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.
Instead of doing this in a do block:
let { ?a = 1; ?b = 2; ?c = 3 } in
doSomethingit is better to do this:
let ?a = 1; ?b = 2; ?c = 3 in
doSomethingWhy? 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 };
doSomethingThis 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;
doSomethingThis causes an 'Unexpected do block' compile error, which is good because it means
this indentation only works for let ... in expressions.
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 = undefinedYou 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 2You 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 rwithCtxData acts like a runTransformerT function for mtl style typeclasses.
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 rcompared 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 rNotice 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
letthat 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.
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.