Skip to content

Fix #4364: Try SAM type when no candidates found #4821

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

Closed
wants to merge 7 commits into from
Closed

Fix #4364: Try SAM type when no candidates found #4821

wants to merge 7 commits into from

Conversation

Medowhill
Copy link
Contributor

No description provided.

Copy link
Member

@dottybot dottybot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello, and thank you for opening this PR! 🎉

All contributors have signed the CLA, thank you! ❤️

Have an awesome day! ☀️

case SAMType(mtp) =>
val sam = narrowByTypes(alts, mtp.paramInfos, mtp.resultType)
if (sam.nonEmpty && !pt.classSymbol.hasAnnotation(defn.FunctionalInterfaceAnnot))
ctx.warning(ex"$pt does not have the @FunctionalInterface annotation.", ctx.tree.pos)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still don't think this is worth adding a warning for. Even if it is, there should be a simple way to turn off this warning.

Copy link
Contributor

@allanrenucci allanrenucci Jul 20, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The simple way is to do explicit eta-expension, no?

new ObjectOutputStream(println)         // warning: are you sure this is what you want to do?
new ObjectOutputStream(() => println()) // Ok
new ObjectOutputStream(println _)       // Ok

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, but if I remember correctly, we plan to deprecate "f _" since just using "f" should work in all situations

val noSam = alts filter (normalizedCompatible(_, pt))
if (noSam.isEmpty) {
pt match {
case SAMType(mtp) =>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would move this case to the enclosing match

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

val candidates = pt match {
  ...
  case SAMType(mtp) =>
    val sam = narrowByTypes(alts, mtp.paramInfos, mtp.resultType)
    if (sam.nonEmpty && !pt.classSymbol.hasAnnotation(defn.FunctionalInterfaceAnnot))
      ctx.warning(...)
    sam
  case pt =>
    alts filter (normalizedCompatible(_, pt))
}

Do you mean fixing the code like the above one?
I think this code would choose the second f instead of the third f for the below code example.

def f(x: Int): Unit = assert(false)
def f(x: String): Unit = assert(false)
def f: java.io.OutputStream = new java.io.OutputStream { def write(x: Int) = () }

val oosF = new java.io.ObjectOutputStream(f)
oosF.write(0)

Do I understand your comment correctly?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. This deserves a comment with an example in the code

case SAMType(mtp) =>
val sam = narrowByTypes(alts, mtp.paramInfos, mtp.resultType)
if (sam.nonEmpty && !pt.classSymbol.hasAnnotation(defn.FunctionalInterfaceAnnot))
ctx.warning(ex"$pt does not have the @FunctionalInterface annotation.", ctx.tree.pos)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ctx.tree.pos here will not be very precise. Ideally it should be the position of the tree being expended:

-- Warning: tests/allan/Test.scala:5:6 -----------------------------------------
5 |  val out2 = new ObjectOutputStream(println)
  |  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Should be:

-- Warning: tests/allan/Test.scala:5:6 -----------------------------------------
5 |  val out2 = new ObjectOutputStream(println)
  |                                    ^^^^^^^

@@ -0,0 +1,13 @@
object Test {
var flag = false
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose you can remove this


def f(): Unit = assert(false)
def f(x: Int): Unit = assert(false)
def f(x: String): Unit = flag = true
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

def f(x: String): Unit = ()

@@ -0,0 +1,23 @@
object Test {
Copy link
Contributor

@allanrenucci allanrenucci Jul 21, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is not clear what you are testing here that is not tested in i4364a.scala. I suggest you add a test to tests/neg-custom-args/fatal-warnings to test the warnings:

class Test {
  def foo(c: java.util.function.Consumer[String]) = ()
  def bar(out: java.io.OutputStream) = ()

  bar(x => println(x))
  bar(println(_))
  bar(println) // error: OutputStream is not @FunctionalInterface

  def f(x: String) = ()
  foo(f) // Ok: Consumer is @FunctionalInterface
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

def f(x: Int): Unit = assert(false)
def f(x: String): Unit = assert(false)
def f: java.io.OutputStream = new java.io.OutputStream { def write(x: Int) = () }

val oosF = new java.io.ObjectOutputStream(f)
oosF.write(0)

Through the test, I tried to check that whether method overloading is correctly resolved when candidates exist without considering SAM type. In this case, the second f is valid choice when it is eta-expanded and OutputStream is considered as SAM type. However, the third f should be choosed. Therefore, I think this test needs to remain.

As you suggested, it would be better to check a warning in a seperate test. I'll remove

val oosG = new java.io.ObjectOutputStream(g)
oosG.write(0)
oosG.close()

from test b and add a test about a warning in tests/neg-custom-args/fatal-warnings.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. I suggest a simpler test though:

import java.util.function.Consumer

object Test {
  def foo(c: Consumer[String]) = c.accept("")

  def bar(x: String): Unit = ???
  def bar: Consumer[String] = new Consumer {  def accept(s: String) = () }

  def main(args: Array[String]) = {
    foo(bar)
  }
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that this one is simpler and clearer. I'll fix it. Thank you!

@@ -1274,7 +1274,7 @@ trait Applications extends Compatibility { self: Typer with Dynamic =>
* Two trials: First, without implicits or SAM conversions enabled. Then,
* if the fist finds no eligible candidates, with implicits and SAM conversions enabled.
*/
def resolveOverloaded(alts: List[TermRef], pt: Type)(implicit ctx: Context): List[TermRef] = track("resolveOverloaded") {
def resolveOverloaded(alts: List[TermRef], pt: Type, pos: Position = NoPosition)(implicit ctx: Context): List[TermRef] = track("resolveOverloaded") {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you need a default value here?


case defn.FunctionOf(args, resultType, _, _) =>
narrowByTypes(alts, args, resultType)

case pt =>
alts filter (normalizedCompatible(_, pt))
val noSam = alts filter (normalizedCompatible(_, pt))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think noSam is not a good variable name. Maybe alts1

val noSam = alts filter (normalizedCompatible(_, pt))
if (noSam.isEmpty) {
/*
* the case should not be moved to the enclosing match
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe instead:

// We only consider SAM types when there is no alternative.
// In the example below, the second overload of `bar` should
// be preferred over the first one:
//
//   def foo(c: Consumer[String]) = ...
//   def bar(x: String): Unit = ...
//   def bar: Consumer[String] = ...
//   foo(bar)

foo(f) // Ok: Consumer is @FunctionalInterface

val oos = new java.io.ObjectOutputStream(f) // error: OutputStream is not @FunctionalInterface
oos.write(0)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can remove this line and the next one

object Test {
def foo(c: java.util.function.Consumer[String]) = c.accept("")

def f(x: String): Unit = ()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can remove the first overload of f

case SAMType(mtp) =>
val sam = narrowByTypes(alts, mtp.paramInfos, mtp.resultType)
if (sam.nonEmpty && !pt.classSymbol.hasAnnotation(defn.FunctionalInterfaceAnnot))
ctx.warning(ex"${sam.head.designator} is eta-expanded even though $pt does not have the @FunctionalInterface annotation.", pos)
Copy link
Contributor

@allanrenucci allanrenucci Jul 23, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not 100% sure this is the right place to emit the warning. This assumes that this overload will be chosen, and its argument will be eta-expanded . Although I couldn't come up with an example where the warning is emitted but no eta-expansion happen.

I would emit the warning where the eta-expansion is performed

@@ -2321,9 +2321,11 @@ class Typer extends Namer
if (arity >= 0 &&
!tree.symbol.isConstructor &&
!ctx.mode.is(Mode.Pattern) &&
!(isSyntheticApply(tree) && !isExpandableApply))
!(isSyntheticApply(tree) && !isExpandableApply)) {
if (!pt.classSymbol.hasAnnotation(defn.FunctionalInterfaceAnnot))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You need to exclude function classes (i.e. defn.isFunctionClass)

@Medowhill
Copy link
Contributor Author

I found the following comment from the 1331st line of Applications.scala.

This private version of resolveOverloaded does the bulk of the work of overloading resolution, but does not do result adaptation. It might be called twice from the public resolveOverloaded method, once with implicits and SAM conversions enabled, and once without.

In the current version of the public resolveOverloaded method, the private resolveOverloaded method is called without Mode.ImplicitsEnabled at the first time. If there are no candidates, it is called again with Mode.ImplicitsEnabled. I think that currently there is no mode corresponding to SAM conversion.

How do you think about the following fix?

Adding

val SAMConversionEnabled = newMode(...)

to Mode.scala.

Changing public resolveOverloaded to

var found = resolveOverloaded(alts, pt, Nil)(ctx.retractMode(Mode.ImplicitsEnabled).retractMode(Mode.SAMConversionEnabled)
if (found.isEmpty && (ctx.mode.is(Mode.ImplicitsEnabled) || ctx.mode.is(Mode.SAMConversionEnabled)))
  found = resolveOverloaded(alts, pt, Nil)

Changing private resolveOverloaded to

val candidates = pt match {
  ...
  case SAMType(mtp) if ctx.mode.is(Mode.SAMConversionEnabled) =>
    narrowByTypes(alts, mtp.paramInfos, mtp.resultType)
  case pt =>
    alts filter (normalizedCompatible(_, pt))
}

@Medowhill
Copy link
Contributor Author

I thought again about the above fix. It will result unnecessary redundant calculations. Would it be better to fix the comment to explain the current code correctly and keep the current code?

@allanrenucci
Copy link
Contributor

I thought again about the above fix. It will result unnecessary redundant calculations. Would it be better to fix the comment to explain the current code correctly and keep the current code?

Your proposed fix looked reasonable to me (except that you can reuse Mode.ImplicitsEnabled and don't need to introduce a new mode). I tried it out and was able to come up with a test that would not compile:

import java.util.function.Consumer

class Test {
  def foo(c: Consumer[String]) = 1
  def f(x: Int): Unit = ()
  def f: Int = 1
  implicit def bar(x: Int): Consumer[String] = ???

  foo(f) // error: none of the overloaded alternatives of method f match expected type Consumer[String]
}

So I would say, we leave it as is for now. #4831 is a related issue I found while looking at it

!(isSyntheticApply(tree) && !isExpandableApply))
!(isSyntheticApply(tree) && !isExpandableApply)) {
pt match {
case SAMType(_) if !defn.isFunctionType(pt) && !pt.classSymbol.hasAnnotation(defn.FunctionalInterfaceAnnot) =>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The extractor is more expensive than the other tests, I would reorder as follow:

/** Is this a SAM type without the @FunctionalInterface annotation? */
def isNonFunctionalSamType =
  !pt.classSymbol.hasAnnotation(defn.FunctionalInterfaceAnnot) &&
  !defn.isFunctionType(pt) &&
  SAMType.unapply(pt).isDefined

Copy link
Contributor

@odersky odersky left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The change to overloading looks like the right thing.

I'd like to discuss a bit more the warning if the expected type of an eta expansion is not a FunctionalInterface. It might end up to be the best thing we can do, but we should discuss it once more.

Looking at the documentation of @FunctionalInterface, it seems to play a role much like @tailrec in Scala: Whether an interface counts as a functional interface is decided structurally, but a @FunctionalInterface annotation makes sure that the user gets an error of a type is not structurally a functional interface.

It seems an overstretch to use the same annotation for deciding whether to warning when eta-expanding to yield instances of SAM types. On the other hand, the status quo where we do so silently can give nasty surprises. So, possible choices are:

  • don't warn (the status quo)
  • make it a warning, as this PR does
  • make it an error

I also have a tendency to make it a warning. But would like to get feedback from others. /cc @adriaanm

@odersky odersky assigned odersky and unassigned allanrenucci Jan 12, 2019
if (!pt.classSymbol.hasAnnotation(defn.FunctionalInterfaceAnnot) &&
!defn.isFunctionType(pt) &&
SAMType.unapply(pt).isDefined)
ctx.warning(ex"${tree.symbol} is eta-expanded even though ${pt.classSymbol} does not have the @FunctionalInterface annotation.", tree.pos)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small nitpick: I'd change the condition to

if (!defn.isFunctionType(pt))
  pt match {
    case SAMType(_) if !pt.classSymbol.hasAnnotation(defn.FunctionalInterfaceAnnot) =>
      ...
    case _ =>
 }

@adriaanm
Copy link
Contributor

I think it's a good idea to warn. Since Java users are encouraged (though not required) to express their intent with the @functionalinterface annotation it's likely that they will. So, the omission of the annotation is a good indication we shouldn't be treating the interface like a SAM. I'd be happy to have this improvement backported to Scala 2!

@odersky
Copy link
Contributor

odersky commented Jan 15, 2019

Superseded by #5717.

@odersky odersky closed this Jan 15, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants