Why not implicit parameters?

Uh, I don’t think -Wtype-defaults has anything to do with the central argument of the post. That’s a completely unrelated warning. The problem Chris is pointing out isn’t about type defaults; it’s about the fact that where the constraints inferred or specified in signatures end up becomes much more impactful when the implicit values the compiler is threading around aren’t coherent, as they (usually) are with instances.

It’s definitely a sharp tool that doesn’t cut in the direction of the rest of Haskell, and I think the advice to beginners to avoid it is spot on.

8 Likes

Correct. Here’s an example that’s warning free and demonstrates the same problem. The output is

ghci> example 
("123","456")

but if you remove NoMonomorphismRestriction the output is

ghci> example 
("123","123")

It’s interesting, because, with Bluefin, I’m not only enjoying being explicit about monadic effects, I’m also enjoying explicitly passing the effects themselves! I’m finding that, with time, I want to be more explicit, not less.


{-# LANGUAGE ImplicitParams, NoMonomorphismRestriction #-}

terror :: (?myparam :: String) => (String, String)
terror =
  let result = ?myparam
   in ( result
      , let ?myparam = "456"
         in result)

example :: (String, String)
example = let ?myparam = "123" in terror
9 Likes

I still think it’s a pretty unrealistic situation in practice using this extension. Having seen it used in many different projects.

That point stands, and using it to scare people off the extension (without explaining that it is an edge case and tbh arguably a bug in the extension) is not helpful.

I actually don’t understand why -Wno-type-defaults doesn’t fire in the string case but not the other. The string literal here feels the same as a numeric literal? Due to -XOverloadedStrings.

Also…there’s no mention of “beginners” in this thread or the blog post. I get the argument to tell beginners to avoid it. But the advice is stronger than that. Which I take issue with.

But OverloadedStrings is not on.

1 Like

Ah yes my bad. I refreshed my memory of the Bad Code in question. The warning thing I found wasn’t a correct way to disagree with it.

But my point stands - the example is 1) intentionally pathological, 2) not actually scary - it makes sense if you have an understanding of the extensions you chose to enable, and 3) meant to scare and not explain. But a blog post that actually explained the “issue” more thoroughly would just make obvious that the code is pathological, which would make the blog post less persuasive :face_with_hand_over_mouth:

Again, I honestly think it’s quite the leap to act like this example demotivates -XImplicitParams as a useful extension. And the blog post is literal FUD.

Haskell has a minor cultural problem imo that results in a lot of argument to bloggers authority instead of just evaluating code as code in the small. It’s a shame but it is what it is. My advice, as always, is to ignore this kind of “don’t do X” blog post. I am very glad I ignored them categorically over my Haskell learning years - because I’d have hamstrung my mind if I didn’t :wink:

1 Like

I think you might be overstating the case. The only thing in the blog post that isn’t purely factual is

Be afraid, be very afraid.

Even when Chris says “avoid it” he’s only saying that he will avoid it, not telling anyone else to.

For me, this is reason enough to avoid it.

If you think blog posts are so convincing then perhaps you could write a blog post about why ImplicitArguments are great!

7 Likes

Along with the very real coherence issue mentioned in the post, I’ll just note that I don’t find implicit params any clearer or easier to write than ReaderT. Rather, everything in the “chain” of calls still requires them in type signatures, and so the code is no less verbose, while also having more syntactic forms to reason about.

In a code-style where using type signatures is more rare and we tend to let all types be inferred, there’s perhaps some benefit – but that’s not the standard modern Haskell style, where we in fact get warnings about missing signatures!

That said, with a few exceptions, I’m not a big fan of the reader monad either, which often feels verbose and like overkill as well, unless we’re forced to be in a monadic chain for other reasons. Instead, I just tend to want to use explicit parameters instead of implicit ones. The “inconvenience” of passing down a config object manually through a chain of calls is more than offset by how clear and straightforward the resulting code becomes.

One potential exception to this is when there’s a big config object at the top – possibly the result of a lot of detailed command line flags. Then, having different monads to reflect different context where increasingly refined fragments of that big config are made available can help with clarity and ensuring there’s not “conceptual leakage” where a bunch of irrelevant options are passed into functions that shouldn’t make use of them.

2 Likes

Whilst I agree about the type signature the code is still less verbose with implicit parameter, because you don’t have to write the parameter at call site and this can make a massive difference especially when you are refactoring.

Moreover, implicit parameter allows you to give a name (and therefore add semantic) to a parameter inside the type signature : compare

Day -> IO ()

with

(?today :: Day) -> IO ()

The compiler will even check that the name (semantic) matches. If change the signature to ?(yesterday :: Day) -> IO () the code won’t compile anymore (which is no the case if I just change the name of an explicit parameter).

I personnaly use implicit param as a semi-global variable, I find it safer than the unsafePerformIO + NOINLINE trick, mainly because I can change the value locally but also because it makes the use of the global variable explicit.

Keeping that in mind I also don’t override the value unless necessary, nor pluck it out of thin air when necessary. What I mean is, in the way I use IP, there is an implicit semantic to the parameter, all functions using the same Implicit are actually refering to the same.

A good example (I actually use) is ?today. You might for example generate a report which today’s date.
The header might need today’s date to print the date at which the report has been printed and the body to actually run the report. Let’s say I have a function todayM:: IO Day the naive implementation would be to use todayM every time I need it, as in

header = do
    today <- todayM
    ... use it

body = do
    today <- todayM
    ... use

 report = header >> body

There are many problem with that one it being what happend if A lauch the report 1millisecond before midnight ? header and body will use a different date.

I could add today a parameter, so report becomes

 report = do
     today <- todayM
     header today
     body today

However, nothing tells me that I want the same day for both call, when writing body I’m not sure If should reuse today or not.

However if use IP, the signatures header :: ?today :: Day => IO () and body :: ?today :: Day => IO (), tell me something different. Instead of telling me “give me any day” they tell me “if you already have a ?today use it”. The code becomes

report = do
   today <- todayM
   let ?today = today
   header
   body

Implicit parameters seem to be useful for implementing things like HasCallStack and HasExceptionContext, but I’m wary of using them for “dependency injection” because of the reasons mentioned in this thread.

Also, for certain use cases, I would prefer thread-local storage over implicit parameters as an alternative to ReaderT.

So if you use IPs for your code, you can completely avoid the blog post’s footgun by providing wrappers that manage the IP. No need to force the user to use -XImplicitParams and manually let. Even without the semantic footgun, this is an obvious thing to do anyways because it’s more compositional and ergonomic.

That should have been the conclusion to that blog post btw.

Implicit parameters suffer from various problems:

  • The parameters are just Symbols living in the global namespace. This means that abstraction is impossible and there is a risk of name clashes.

  • If a parameter has been bound multiple times, understanding which one “wins” is not straightforward, except in the simplest cases.

  • Parameters don’t get along well with the monomorphism restriction. Chris Done’s post shows an example of this problem.

  • Implicit parameters are implemented by piggybacking on type classes, which have been designed for static, global resolution. GHC’s code base is full of ad-hoc checks and special cases to force the IP class to behave as expected and forbid setting it as a superclass (which would break global coherence). In hindsight, IP should probably be a different kind of constraint.

That being said, conceptually I prefer implicit parameters to Reader because they are more composable (incidentally I am working on a library that solves the first two problems).

5 Likes

prefer implicit parameters to Reader because they are more composable

Indeed, this is the main argument for them. One can get something similar with reader over an HList-style typed map, but at a rather painful cost to type inference. The underlying issue is lack of first-class support for extensible records, although things are much better these days with our simulations of such.

1 Like

The parameters are just Symbols living in the global namespace.

Not quite true. ?symbol :: T is indeed just a Symbol, but you can use IP type class with locally defined dummy data tags: Haskell Playground

In this example MyDummyTag can’t clash with any "symbol" and you can hide it from the exports.

As @Ambrose mentioned above

you can completely avoid the blog post’s footgun by providing wrappers that manage the IP

We already have a plain function for binding IP constraint, it’s withDict from base.

If a parameter has been bound multiple times, understanding which one “wins” is not straightforward, except in the simplest cases.

I think that the answer here is “just be more explicit about implicits”

Implicit parameters are implemented by piggybacking on type classes, which have been designed for static, global resolution.

Wrong! See the story around withDict and co. There is a lot of work aside implicit params that is done in GHC to resolve type classes with the right scoping. For example, see GHC 9.8 release notes:

GHC now deals with “insoluble Givens” in a consistent way. For example:

k :: (Int ~ Bool) => Int -> Bool 
k x = x

GHC used to accept the contradictory Int~Bool in the type signature, but reject the Int~Bool constraint that arises from typechecking the definition itself. Now it accepts both. More details in #23413, which gives examples of the previous inconsistency. GHC now implements the “PermissivePlan” described in that ticket.

I can’t advise one to use implicit params in user-facing API of a library, but I think that they are great for passing arguments locally in the algorithm.

1 Like

I’m curious: are there other elements of programming style which we encourage locally in an algorithm but not in an API? I can think of one case: low-level primitives that have better performance but raise safety concerns. Are there other cases?

Here are some other examples:

  • -XPartialTypeSignatures or bindings without type signatures
  • One-letter variables (at least in Haskell)
  • Shadowing common names (e.g. local variable with name head or id)

Ah, by “locally in an algorithm” did you mean “in a local let-binding”? I assumed you meant at module level (where you wouldn’t tend to see partial type signatures, one-letter variable names, or shadowing common names even for things that are not exported). And if so, are you saying that implicit parameters are not great in top-level functions, even if those functions are not exported?

No, I mean module level for implicit params, my other examples are just more local.

P.S. I will definitely accept partial type signatures for module level private functions, if that’s acceptable by the project’s code style.

OK, so to be clear, I’m specifically interested in examples of things you would use at the same level as you’re suggesting to use implicit params. So, so far we have

  • primitives: they exist at module top level but we don’t normally expose them in APIs
  • partial type signatures

Do any other examples come to mind?

I think there’s a lot of potential in implicit Proxys to guide inference and otherwise do cool things in eDSLs GitHub - ramirez7/icfp-2023-inference-tricks

Nice trick! I’ve been experimenting with something similar recently. But that’s not how the ImplicitParams extension is meant to be used. The GHC manual doesn’t talk about this nor I’ve seen it suggested anywhere. In fact until yesterday I thought I was the only one who used this technique.

I think I was misunderstood here. I’m not saying that local constraints are not possible in GHC. I’m saying that type classes have been originally designed with global resolution in mind.

Implicit parameters and WithDict introduce different mechanisms but they still look like afterthoughts to me. The implementation of IP relies on ad-hoc checks (see isIPLikePred), the semantics of WithDict are unclear and undocumented.

ghci> class GivenInt where giveInt :: Int
ghci> instance GivenInt where giveInt = 42

ghci> withDict @GivenInt 1 giveInt 
1

ghci> withDict @GivenInt 1 $ withDict @GivenInt 2 giveInt
1

ghci> withDict @(IP "int" Int) 1 $ ip @"int"
1

ghci> withDict @(IP "int" Int) 1 $ withDict @(IP "int" Int) 2 $ ip @"int"
2

In these examples withDict overrides the global GivenInt instance, but the nested withDict call doesn’t override the outer one.
However when applying withDict to IP the innermost instance wins.

This can only be discovered by trial and error. It isn’t documented anywhere, not even in the GHC code base as far as I know (correct me if I’m wrong).

Ideally I would like to see:

  • Well-defined and documented semantics.
  • A clear difference between global constraints, local constraints (meant to be set with WithDict) and implicit parameters. I don’t know, maybe a kind-level distinction à la TYPE :: RuntimeRep -> Type.

Until then, I agree with you that implicit parameters are not a good idea in a user-facing API. They should only be use internally and only if you know their limitations and quirks.

Edit:
Upon reflection, WithDict's behavior in my example is a bug. The fact that it’s allowed to override a statically-defined instance breaks global coherence. I’ll open a ticket on the GHC bug tracker.

5 Likes