Closed radrow closed 2 years ago
IMO implicits are one of the features that I really don't like about Scala. They make it very difficult to reason about what any particular piece of code does as the implicit could be brought in from anywhere, including imports. Even if C# kept that much more limited it still means an identifier declared in one place directly effects any number of call sites without being able to see that in code.
I appreciate the amount of detail and effort you put into this proposal. However, as detailed in our Readme, all suggestions should start as discussions, not full issues, so I'm converting this to a discussion.
Implicit parameters
Summary
In this proposal, I would like to draw your attention to a feature called implicit parameters. My main inspiration comes from Scala, where it is known under this exact name, but some variations are present in other languages as well. The motivation is to increase clarity and comfort of writing and refactoring code that extensively passes environmental values through the stack.
Implicit parameters are syntactic sugar for function applications. The idea is to pass selected arguments to methods without necessarily mentioning them across the code, especially where it would be repetitive and unavoidable. Thus, instead of writing
One could simplify it to something like:
Note that
session
is provided implicitly to every call that declares it as itsimplicit
argument. While it still needs to be declared in the function signature, the application is handled automatically as long as there is a matching implicit value in the context.Motivation
Since it is just a "smart" syntactic sugar, this feature does not provide any new control flows or semantics that were not achievable before. What it offers is that it lets one write code in a certain style more conveniently, and with less of boilerplate.
The promoted paradigm is to handle environment and state by passing it through stack, instead of keeping them in global variables. There are numerous benefits of designing applications this way; most notably the ease of parallelization, test isolation, environment mocking, and broader control over method dependencies and side effects. This can play crucial role in big systems handling numerous tasks in parallel, where context separation is an important security and sanity factor.
The previous snippet served as a brief visualization, so it does not necessarily capture the whole point. To get a more elaborate view, let us consider a more realistic example of a gRPC server converting image files:
It is surely a matter of subjective opinion, but to me the snippet seems much cleaner than what I imagine it would look if it passed around
ctx
,inStream
andoutStream
explicitly every time. The code focuses on the main logic without bringing up the contextual dependencies, which are mentioned only in method headers.A nice addition is that implicits can make refactoring easier in some cases. Let us imagine that it turns out that
RequestNoFile
needs to check for cancellation, and therefore requiresServerCallContext
to get access to the token:Because in the presented snippet
RequestNoFile
is called only from scopes withServerCallContext
provided, no other changes in the code are required. In contrast, without implicit parameters, every single call toRequestNoFile
would have to be updated. Of course, if the calling context does not have that variable, it needs to get it anyway -- but if it does so implicitly as well, this benefit propagates further. This nicely reduces the complexity of adding new dependencies to certain actions.Inspirations
This feature is inspired by mechanisms found in other languages. My experience is mostly developed in Scala and Haskell, so I hereby present their approach to this.
Scala
In Scala every method can be equipped with a separate set of arguments marked as
implicit
:First important note: Scala allows multiple implicit parameters. They are resolved by types, so it is invalid to have many of them sharing a common type. Therefore, the advised style is to always have a dedicated class for each one:
Next, note how implicit parameters are provided from an implicit-free context. In the first example, they are not given to the
sendRequest
method directly, but are declared asimplicit
in prior. That instructs the compiler to use them as arguments to methods that need them.A thing that disturbs me with how it is implemented here, is that implicit variables do not have to be introduced anywhere near the method call. In fact, they can come from a separate module via an
import
. While the whole point is to make it, well,implicit
, to me this goes way too far. This can leave the programmer perplexed about the source of the arguments, which can lead to convoluted bugs.UPDATE they apparently revisited that heavily. My knowledge might be then outdated, please check out this post for a more recent view.
Haskell
While Haskell does not support implicit parameters directly, it offers more general mechanisms that can be used to achieve pretty equivalent behavior -- the
Reader
monad. I am not going to start a discussion about monads, so please allow me to be slightly imprecise in this section.In Haskell we write functions like this:
Reader
lets to have one implicitly passed argument (which can be a tuple or a record, if more are needed). In order to do so, one needs to declare the return type of a function asReader env ret
whereenv
is the implicit argument, andret
is the actual return type. In action, it looks as follows:Note some key differences compared to Scala:
<-
operator), but it is possible to go without it as well.Session
andExecutionContext
), so a tuple is used. While this solves the problem with ambiguity, it enforces manual conversion when calling a function that needs only aSession
for example.Reader
, implicit parameters are provided explicitly using the dedicatedrunReader
function. There is no way they come from other module or cosmic space.What I like about it, is the clarity of the implicit behavior. In fact,
Reader
is not a language construct, but a casual type defined in a dedicated library. Therefore, it is easy to inspect its exact logic just by looking into its code. However, Haskell and C# differ massively, so this model is not entirely transportable. But it also does not mean we cannot get any inspirations from it.Detailed design
General syntax
I have already shown one idea that resembles the way Scala does it. Syntactically, I think it is a quite pretty and compatible with C# option. Therefore, my first idea it to extend the method declaration syntax to optionally include a second parameter list prepended with a single
implicit
keyword:Alternatively, implicit parameters could stay together with optional parameters to prevent double parameter lists. In fact, their behavior is quite similar to the one provided by
CompilerServices
, such asCallerMemberName
. Therefore, a less intrusive designs emerge:I personally like the "syntax-driven", single-parameter version the most. The main argument for it is compatibility with existing language constructions, while keeping neat looking syntax.
Supplying implicit arguments from non-implicit methods
As I said, I do not enjoy the way the arguments are picked in Scala. In my opinion, there should always be a clear way of finding the sources of implicit parameters. Therefore, I propose letting them be taken only:
Hence this:
instead of this:
...and definitely not this
On the other hand, declaring variables for implicit use may be handy in some cases. It does not stay in conflict with the ability to perform explicit applications to implicit arguments as well. But then there comes a question: what restrictions would we pose on it? Should the parameters be allowed to be taken only from the current block, method, class, namespace or any imported module? Maybe they deserve some special place, such as beginning of the current block? The more flexibility here, the less trivial the resolution is -- not only for the compiler, but more importantly for the programmer.
I think the safest option would be to start with how I proposed it (so to pass arguments implicitly only when they are implicit parameters of the current method). Then we could see how much a limitation it is, and pay attention to possible demands on extending it further.
If supplying them manually starts getting annoying, then a possible workaround would be to lift the context with another method. So this:
turns into this:
Resolution of multiple implicit parameters
The design must consider ambiguities that emerge from use of multiple implicit parameters. Since they are not explicitly identified by the programmer, there must be a clear and deterministic way of telling what variables are supplied and in what order.
By type
Scala for instance allows only one implicit variable of a given type in every scope. That on one hand, makes the resolution easy, but on the other limits the flexibility to some extent. I am also unsure how it deals with subtyping (I assume it requires the type to match exactly). The preferred way of dealing with it is to create a separate class for every kind of implicit parameter. C# has more verbose syntax for class declaration, so I am slightly afraid that it would come out annoying.
By name
Yet another way of tackling this, is to make the resolution dependent on the name of the parameter instead. While it allows multiple parameters sharing the same type, as well as using subtypes to be supplied, the downside of it is that it fixes argument names across the whole flow. It is a certain limitation, but on the other hand is it necessarily bad, especially considering the preference that these arguments should generally serve the same purpose all the way down? If one would want to change the name in the middle, an explicit application could still be used.
Not at all
In the end, only a single implicit parameter could be allowed. If someone wants more, then record types could be used. A limitation would be noticeable in cases where a subset of the implicit arguments would be needed in a local call (recall the gRPC example, where
RequestNoFile
did not need theinStream
, but requiredoutStream
). While this could be possibly solved with inheritance on that record, I do not see big benefits over the by-name resolution of multiple parameters.Backwards compatibility
Since I propose reusing an existing keyword, all valid identifiers should remain valid. The only added syntax is an optional set of parameters, which currently does not serve any purpose, so no conflicts would arise from that either. Therefore, the feature should not break nor change the meaning of any existing code.
Performance
These parameters turn into normal ones in an early phase of the compilation, so no runtime overhead at all. Compilation time would be affected obviously, but it depends on the resolution algorithm. If kept simple (what I believe is achievable), the impact should not be very noticeable. More than that, there is no overhead if the feature is not used.
Editor support
Since the feature would be desugared quite early, it should be easy to retrieve what arguments are applied implicitly. Thus, if some users find it confusing, I believe it would not be very hard to have a VS (Code) extension that would inform about the details of the implicit application. A similar thing to adding parameter names to method calls.
Drawbacks
Well, "implicit". This word is sometimes enough to bring doubts and protests. As much as I personally like moving stuff behind the scenes, I definitely see reasons to be careful. All that implicit magic is a double-edged sword -- on one hand it helps keeping the code tidy, but on the other can lead to nasty surprices and overall degraded readability.
One of the most common accusations against Scala is the so-called "implicit hell", which is caused by sometimes overused combination of extension classes (known there as, of course, "implicit" classes), implicit parameters and implicit conversions. I am not a very experienced Scala programmer, but I do remember finding Akka (a Scala library that uses implicits extensively) quite hard to learn because of that.
As mentioned before, there is an article by Scala itself, that points out flaws in the Scala 2 design. I encourage the curious reader for a lecture on how not to do it.
Also, there is a discussion under a non-successful proposal for adding this to Rust. The languages and priorities are fairly different, but the critics there clearly have a point.
Alternatives
Here are a bunch of alternative ways of implementing implicit parameters. I do not think they are really comparable to the ones I proposed, but decided to leave them for inspirational purposes.
Haskell style
One alternative approach is to imitate the mentioned
Reader
from Haskell. Thus, instead of adding a new parameter list, a wrapper for the return type is created, that informs what is the implicit parameter:What I find unfavorable here, are the unobvious "casts" between wrapped and unwrapped types. I am talking especially about the line with
int x = f(3)
, where the type ofx
isint
, despite the fact thatf
returnsWithEnv
. At first glance it is similar to what happens withTask
inasync
methods, but there the conversion is indicated explicitly with theawait
keyword. Here coming with a similar solution would weaken the implicitness, which in my opinion contradicts the whole point.Haskell solved it by using a different
<-
assignment operator, and in OCaml they writelet! x = ...
instead oflet x = ...
. While it could work here as well, I do not think that adding a new operator just for this particular feature would be worth the hassle. Both languages have it widely generalized, so it made sense there.Python style
We can always use method annotations (a.k.a. "decorators")
What I do not like about this, is that it separates implicit parameters from the type signature. To me, implicit parameters are an unseparable part of the method's type, so delegating them outside stays against the intuition. More than that, I believe it would make it harder for the language and tooling to support the feature properly. Last, it is very verbose and looks awful.
Unresolved questions
Design meetings