Proposal To Revise Implicit Parameters

So another proposal can’t hurt, right? So I thought I’d drop in my own! This is actually my first time writing a proposal like this, it was pretty fun! :slight_smile:

This does involve both the syntax for taking in implicits and declaring them, but I couldn’t figure out how to meaningfully split it up across the two discussion topics. This ended up being pretty long, so I can put this as a separate topic if people think that would be a good idea.

Overall Idea

I got this idea after speaking with some friends from my high school (now a mix of first year college students and junior/senior high school students), where I led the use of Scala as the core language for our robotics team. We had used implicits quite heavily in our robotics framework, but definitely saw similar issues as those reported in this thread with implicits being hard to pick up.

The summary of our discussions was that the core issue with implicits is not the syntax or overall idea, but instead the confusion that happens when they don’t “just work”. In this situation, it becomes quite difficult to guess (at a conceptual/mental level) where implicit parameters were (or were unable to be) sourced from especially because there are multiple ways implicit parameters can be synthesized.

As I see it, implicit parameters serve two core and rarely overlapping roles: to provide context (“repetitive arguments” in the original proposal, such as the CorrelationId argument) and to derive instances of typeclasses (and similar constructs that associate a type with some behavior). So this proposal splits up implicits into two separate concepts: context and derived.

Trying to write out a detailed proposal, I tried a couple experiments, so I decided to add in all the experiments after the main proposal here with comments on what I was aiming to do with each and why I decided to drop the ones I did.

Context

The first and more straightforward part here is context, since passing it down is already a simpler concept. The goal here is simple: reduce the amount of code written when some context is repeatedly passed down through functions.

One special property of context when passed down in programs is that it is a fixed instance that does not need to be generated at each level. Functions simply take in the context and pass it down to functions in their body that need it. To match this, context values must always be defined as vals.

case class Config(appName: String)

// elsewhere...

object MyApp {
  context val config = Config("Demo App")
}

Restricting context definitions to vals cuts down on the potential sources for where a context parameter could have come from, which reduces the mental load for developers trying to understand where a context value was sourced.

To take in context, the syntax is identical to implicits right now:

def displayName(name: String)(context config: Config): String = {
  s"My name is $name -- generated by ${config.appName}"
}

Passing through context is also the same:

def displayNames(names: Seq[String])(context config: Config): String = {
  names.map(displayName).mkString("\n") // displayName eta-expanded to (name) => displayName(name)(config)
}

Manually passing in context is a bit different, it requires an explicit context keyword to clarify that this is a special type of parameter and to distinguish it from derived later:

test("displayName shows correct app name suffix") {
  assert(displayName("Test Name")(context Config("Test App")) ==
    "My name is Test Name -- generated by Test App")
}

implicitly[T] here is replaced with context[T]

Relation to Implicit Functions

Context values should work well with implicit functions, which (at least in the code I have seen/imagined) would usually take some context value to reduce the repetitiveness of taking parameters over and over. More analysis is needed to understand how these two concepts would interact with each other.

Slight Aside: Similarities to React

As I wrote down this proposal, I realized that context here aligns very closely to context in React.js, which has been a very successful feature. In React, context can be added to an application by injecting it with a context provider component, which makes the context available to components rendered inside it.

(from the React docs)

// Context lets us pass a value deep into the component tree
// without explicitly threading it through every component.
// Create a context for the current theme (with "light" as the default).
const ThemeContext = React.createContext('light');

class App extends React.Component {
  render() {
    // Use a Provider to pass the current theme to the tree below.
    // Any component can read it, no matter how deep it is.
    // In this example, we're passing "dark" as the current value.
    return (
      <ThemeContext.Provider value="dark">
        <ThemedButton />
      </ThemeContext.Provider>
    );
  }
}

class ThemedButton extends React.Component {
  // Assign a contextType to read the current theme context.
  // React will find the closest theme Provider above and use its value.
  // In this example, the current theme is "dark".
  static contextType = ThemeContext;
  render() {
    return <Button theme={this.context} />;
  }
}

The context provided is always sourced from a fixed value (passed in as the value property). As it moves down the tree, the only way context can be modified is to re-inject a replacement value with another provider. This mirrors the proposal for context here, where context can be injected from a fixed context val value and can only be replaced down the function tree by explicitly passing in an alternative context.

Derived

Now comes the second half of what implicits let us do: derive behavior for specific types. Splitting this part off into a separate construct both helps developers conceptualize where values come from (just like context) and also aligns the feature with equivalents in other languages like Rust derived traits.

When declaring a derivation for a particular typeclass, there are four pieces of information: a name for the derivation to distinguish it from others, other context and derived values it needs, what type of object is being derived, and finally the actual implementation. Method declarations have a similar format, so I decided to just adapt that format with def replaced with derived.

trait Reader[T] {
  def read(str: String): T
}

object Reader {
  // first, a simple reader for parsing ints
  derived intReader = new Reader[Int] {
    def read(str: String) = str.toInt
  }

  // equivalent to

  derived intReader: Reader[Int] = _.toInt // single abstract method style
}

Taking in derived parameters is just like implicits (and thus like context). With derived definitions and parameters combined, we can then have recursive derivations.

derived seqReader[T](derived elemReader: Reader[T]) = new Reader[Seq[T]] {
  def read(str: String) = str.split(' ').toSeq.map(elemReader.read)
}

Finally, derivations can be manually passed in with a derived keyword marking the set of parameters as belonging to that set.

// parses strings like 1,2,3 4,5,6 to Seq(Seq(1, 2, 3), Seq(4, 5, 6))
def parseSpacesThenCommas[T](str: String)(derived elemReader: Reader[T]): Seq[Seq[T]] = {
  val commasReader = new Reader[Seq[T]] {
    def read(str: String) = str.split(',').toSeq.map(elemReader.read)
  }
  
  // things are much clearer when using named parameters, but I'm not sure if that should be enforced
  seqReader(derived elemReader = commasReader).read(str)
}

implicitly[T] here is replaced with derived[T]

Migration from Scala 2

To handle migration to this new style, I would propose allowing old-style implicits to fulfill both context and derived values, since this proposal just splits the two uses of implicits but does not significantly change the semantics of either. Similarly, context and derived can fulfill implicit parameters.

Experiment 1: anonymous context

Often times a context parameter will be taken in, not used in the body, but just passed down to another function. In this case, having to name all the context parameters adds unnecessary overhead. So one idea is to just make naming context values optional (then the two syntaxes are name: Type or just Type separated by commas). For example, this would enable usage such as:

def displayNames(names: Seq[String])(context Config): String = {
  names.map(displayName).mkString("\n")
}

This doesn’t complicate the syntax too much and could clean up a lot of code, so I think this would be a helpful addition to the proposal.

Experiment 2: type-named context

Inspired by React’s API for getting context values with contextType (used in the above example), this experiment drops context parameter names completely in favor of identifying context data by the type.

def displayName(name: String)(context Config): String = {
  s"My name is $name -- generated by ${Config.appName}"
}

While a nice shorthand, I am afraid this will cause too many conflicts with companion objects and is potentially very confusing, so I don’t think this should be implemented.

3 Likes