Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Can't Abstract Over Functions And Context Functions #15901

Open
adamgfraser opened this issue Aug 24, 2022 · 9 comments
Open

Can't Abstract Over Functions And Context Functions #15901

adamgfraser opened this issue Aug 24, 2022 · 9 comments

Comments

@adamgfraser
Copy link
Contributor

In some cases it is necessary to abstract over context functions and ordinary functions.

For example, consider a function that would eliminate a dependency on a capability Foo. We can write this in Scala 3 like this:

trait Foo

def provideContextFunction[A](f: Foo ?=> A): A =
  f(using new Foo {})

def contextFunction(using Foo): Int =
  42

provideContextFunction(contextFunction) // okay

However, we might also want to be able to eliminate an explicit dependency on a capability Foo. This could be particular important for compatibility with Scala 2 where functions that depend on implicit parameters are ordinary functions since context functions do not exist.

For instance, this will not compile:

provideContextFunction(implicit foo => 42) // does not compile

We can do this explicitly:

def provideFunction[A](f: Foo => A): A =
  f(new Foo {})

def function(foo: Foo): Int =
  42

provideFunction(function) // okay
provideFunction(implicit foo => 42) // okay

However, we can't mix and match these:

// provideFunction(contextFunction) does not compile
// provideContextFunction(function) does not eliminate requirement

We also can't give provideFunction and provideContextFunction the same name since they erase to the same type.

This makes it impossible to write an operator that eliminates the dependency on a capability that can be used consistently across Scala 2 and Scala 3.

What we would like to be able to do is write something like:

def provide[A](f: FunctionLike[Foo, A]): A =
  f.apply(new Foo {})

provide(contextFunction) // okay
provide(function) // okay

This is just pseudocode but assumes there is some common super type of functions and context functions that describes something that an argument can be applied to. It doesn't appear that this currently exists and apologies if I missed it.

Maybe there is another solution to this as well such as converting lambdas of the form implicit x => y into context functions but it seems like Scala 2 is still going to be used commercially for a while so it will be important for library adoption of capabilities to be able to eliminate them in a way that works across Scala versions.

@adamgfraser adamgfraser added itype:bug stat:needs triage Every issue needs to have an "area" and "itype" label labels Aug 24, 2022
@Kordyjan Kordyjan added itype:enhancement stat:needs spec and removed itype:bug stat:needs triage Every issue needs to have an "area" and "itype" label labels Aug 24, 2022
@bishabosha
Copy link
Member

bishabosha commented Aug 24, 2022

is this pattern reasonable?

given FnToCtxFn[T, U]: Conversion[T => U, T ?=> U] with
  def apply(f: T => U): T ?=> U =
    t ?=> f(t)

usage:

scala> provideContextFunction(function.convert)
val res1: Int = 42

Edit: Its not so good for the anonymous function use-case

@adamgfraser
Copy link
Contributor Author

@bishabosha Yes exactly:

trait Foo

def provideFoo[A](f: Foo ?=> A): A =
  f(using new Foo {})

given FnToCtxFn[T, U]: Conversion[T => U, T ?=> U] with
  def apply(f: T => U): T ?=> U =
    t ?=> f(t)

provideFoo(implicit foo => 42) // does not compile

@bishabosha
Copy link
Member

I guess you don't want this either:

provideContextFunction((implicit (foo: Foo) => 42).convert)

@adamgfraser
Copy link
Contributor Author

Yeah. The use of these contextual values is already less ergonomic in Scala 2 due to the need to have the implicit foo. Having to do an additional operator at the end of the lambda, for something that you wouldn't have to do otherwise and on something that people may not even be used to thinking of as a value, is a real killer. Also there is a degradation of type inference there where you have to specify the type of Foo instead of it being inferred. So it is:

provide((implicit (foo: Foo) => 42).convert)

Versus:

provide(implicit foo => 42)

@adamgfraser
Copy link
Contributor Author

I tried doing it the other way around, making provideFoo take a regular function and defining a conversion from a regular function to a context function. However, this makes usage with context functions extremely brittle.

import scala.language.implicitConversions

trait Foo

def provideFoo[A](f: Foo => A): A =
  f(new Foo {})

given FnToCtxFn[T, U]: Conversion[T ?=> U, T => U] with
  def apply(f: T ?=> U): T => U =
    t => f(using t)

def contextFunction(using Foo): Int =
  42

def function(foo: Foo): Int =
  42

provideFoo(implicit foo => 42) // okay
provideFoo(contextFunction) // okay
provideFoo(function) // okay

trait Bar

given Bar = new Bar {}

def moreComplexContextFunction(using Foo, Bar): Int =
  42

provideFoo(moreComplexContextFunction) // does not compile

@bishabosha
Copy link
Member

bishabosha commented Aug 24, 2022

If this is for cross compiling code, then with -source:3.0-migration the only change is to use an explicit type:

// probably need to define elsewhere if cross compiled
implicit def FnToCtxFn[T, U]: Conversion[T => U, T ?=> U] = new Conversion {
  def apply(f: T => U): T ?=> U =
    t ?=> f(t)
}


provideContextFunction(implicit (foo: Foo) => 42)

@adamgfraser
Copy link
Contributor Author

That is definitely a step in the right direction. However just having to specify the type parameter there is a significant downside. In one of the code bases I work on we have hundreds of uses of the equivalent of Foo and as much as it seems like a small thing people hate having to specify type parameters like that.

@adamgfraser
Copy link
Contributor Author

Actually I don't think that works. provideContextFunction takes something that returns an A and may depend on Foo. But if we have a normal function Foo => Int the A can be Foo => Int. So like the below compiles but the value returned by provideContextFunction is a lambda, not 42. 😱

trait Foo

implicit def FnToCtxFn[T, U]: Conversion[T => U, T ?=> U] = new Conversion {
  def apply(f: T => U): T ?=> U =
    t ?=> f(t)
}

def provideContextFunction[A](f: Foo ?=> A): A =
  f(using new Foo {})

def function(foo: Foo): Int =
  42

provideContextFunction(function)

@jdegoes
Copy link

jdegoes commented Aug 24, 2022

If context functions are here to stay, and not just an experiment, then I would suggest that abstraction is critical to enable the sort of reuse that Scala excels at.

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

No branches or pull requests

4 participants