Skip to content

fix(#19266): better warning for impure synthetic lambda in stmt position #19540

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

Open
wants to merge 9 commits into
base: main
Choose a base branch
from

Conversation

i10416
Copy link
Contributor

@i10416 i10416 commented Jan 26, 2024

close #19266

Before this PR, checkStatementPurity filtered out impure expression too eagerly and closureDef ignores closure definition generated from eta-expansion on method with default arguments, which prevented impure lambda at statement position from showing a warning.

This PR modifies

  • checkStatementPurity to check expression purity at later so that impure lambda will not slip through the check
  • closureDef to match against a synthetic closure from eta-expansion on method with default arguments

Related to

def fn2(x: Int)(y: String) = Some(s"$x$y")

def checkCompile =
fn1(1)
Copy link
Contributor Author

@i10416 i10416 Jan 26, 2024

Choose a reason for hiding this comment

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

Scala 2.13.12 with -Xsource:3 compiles with warnings; " a pure expression does nothing in statement position".

Scala 2.13.12 without -Xsource:3 raises errors for fn1(1), fn2(2) and fn2(3).

Unapplied methods are only converted to functions when a function type is expected.
You can make this conversion explicit by writing fn2 _ or fn2(_)(_) instead of fn2.

Copy link
Contributor Author

@i10416 i10416 Jan 26, 2024

Choose a reason for hiding this comment

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

Do the following to unapplied methods used as value

What characteristic allows us to assume unapplied methods are actually used as value?

https://github.com/lampepfl/dotty/blob/1716bcd9dbefbef88def848c09768a698b6b9ed9/compiler/src/dotty/tools/dotc/typer/Typer.scala#L3645

   *  (4) Do the following to unapplied methods used as values:
   *  (4.1) If the method has only implicit parameters pass implicit arguments
   *  (4.2) otherwise, if `pt` is a function type and method is not a constructor,
   *        convert to function by eta-expansion,
   *  (4.3) otherwise, if the method is nullary with a result type compatible to `pt`
   *        and it is not a constructor, apply it to ()
   *  otherwise issue an error

Copy link
Contributor Author

Choose a reason for hiding this comment

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

https://github.com/lampepfl/dotty/blob/main/docs/_spec/06-expressions.md

Method Conversions

The following four implicit conversions can be applied to methods which are not applied to some argument list.

Evaluation

A parameterless method ´m´ of type => ´T´ is always converted to type ´T´ by evaluating the expression to which ´m´ is bound.

Implicit Application

If the method takes only implicit parameters, implicit arguments are passed following the rules here.

Eta Expansion

Otherwise, if the method is not a constructor, and the expected type ´\mathit{pt}´ is a function type, or, for methods of non-zero arity, a type sam-convertible to a function type, ´(\mathit{Ts}') \Rightarrow T'´, eta-expansion is performed on the expression ´e´.

(The exception for zero-arity methods is to avoid surprises due to unexpected sam conversion.)

Empty Application

Otherwise, if ´e´ has method type ´()T´, it is implicitly applied to the empty argument list, yielding ´e()´.

Copy link
Contributor Author

@i10416 i10416 Jan 26, 2024

Choose a reason for hiding this comment

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

In the following example m can be partially applied to the first two parameters. Assigning m to f1 will automatically eta-expand

def m(x: Boolean, y: String)(z: Int): List[Int]
val f1 = m
val f2 = m(true, "abc")

(The emphasis is added by me)
What should happen if m is not assigned to value?

https://dotty.epfl.ch/docs/reference/changed-features/eta-expansion-spec.html#automatic-eta-expansion-and-partial-application-1

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@i10416 i10416 changed the title draft:fix(#19266): add test cases draft:fix(#19266): inconsistent compile error for eta-expanded methods Jan 26, 2024
@@ -0,0 +1,9 @@
object i19266:
Copy link
Contributor Author

Choose a reason for hiding this comment

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

note:

  def fn1(x: Int, y: String = "y")(z: Double) = Some(s"$x$y$z")
  def fn2(p: Int)(q: String) = Some(s"$p$q")

  def checkCompile =
    // This is NOT regarded as `isPureExpr`, but I think should be to warn `nonunit-statement`.
    // See https://github.com/lampepfl/dotty/issues/19266#issuecomment-1856248380 
    fn1(1)
    // This is regarded as `isPureExpr` and therefore it results in error.
    fn2(2)

Copy link
Contributor Author

@i10416 i10416 Jan 26, 2024

Choose a reason for hiding this comment

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

Block(
    List(
        ValDef(
            y$1,
            TypeTree[AnnotatedType(TypeRef(TermRef(ThisType(TypeRef(NoPrefix,module class scala)),object Predef),type String),ConcreteAnnotation(Apply(Select(New(TypeTree[TypeRef(ThisType(TypeRef(NoPrefix,module class unchecked)),class uncheckedVariance)]),<init>),List())))],
            Select(This(Ident(i19266$)),fn1$default$2)
        )
    ),
    Block(
        List(
            DefDef(
                $anonfun,
                List(List(ValDef(z,TypeTree[TypeRef(TermRef(ThisType(TypeRef(NoPrefix,module class <root>)),object scala),class Double)],EmptyTree))),
                TypeTree[AppliedType(TypeRef(ThisType(TypeRef(NoPrefix,module class scala)),class Some),List(TypeRef(ThisType(TypeRef(NoPrefix,module class lang)),class String)))],
                Apply(Apply(Ident(fn1),List(Literal(Constant(1)), Ident(y$1))),List(Ident(z)))
            )
        ),
        Closure(List(),Ident($anonfun),EmptyTree)
    )
)

Block(
    List(
        DefDef(
            $anonfun,
            List(List(ValDef(q,TypeTree[TypeRef(TermRef(ThisType(TypeRef(NoPrefix,module class scala)),object Predef),type String)],EmptyTree))),
            TypeTree[AppliedType(TypeRef(ThisType(TypeRef(NoPrefix,module class scala)),class Some),List(TypeRef(ThisType(TypeRef(NoPrefix,module class lang)),class String)))],
            Apply(Apply(Ident(fn2),List(Literal(Constant(2)))),List(Ident(q)))
        )
    ),
    Closure(List(),Ident($anonfun),EmptyTree)
)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

f1(1) is regarded as Block with ValDef statement and Block expr whereas f2(2) is regarded as Block with DefDef statement and Closure.

@i10416
Copy link
Contributor Author

i10416 commented Jan 26, 2024

Note for myself.

Default by-value arguments may have side-effect, so it is reasonable not to warn " A pure expression does nothing in statement position". Perhaps, we should warn NonUnitUnusedValue.

Method without default argument and method with default arguments for by-name parameters are pure and (I think) should show warnings instead of error

It seems an expected behavior for compiler to raise error rather than warning.

#11769

import scala.collection.mutable.ArrayBuffer
import scala.util.Random

val arr = ArrayBuffer[Int]()

def fn0(x: Int)(y: String) = Some(s"$x$y")
def fn1(x: Int, y: Unit = arr.addOne(Random.nextInt()))(z:Double) = Some(s"$x,$y,$z")
def fn2(x: Int, y: => Unit = arr.addOne(Random.nextInt()))(z:Double) = Some(s"$x,$y,$z")

// This is pure and falls into the missing argument arm
// here https://github.com/lampepfl/dotty/blob/1716bcd9dbefbef88def848c09768a698b6b9ed9/tests/pos-with-compiler-cc/dotc/typer/Typer.scala#L4203
fn0(0)
// This is impure(perform side-effect to mutate `arr`). Therefore this passes through `checkStatementPurity`.
fn1(1)
// This is pure and falls into the missing argument arm 
// https://github.com/lampepfl/dotty/blob/1716bcd9dbefbef88def848c09768a698b6b9ed9/tests/pos-with-compiler-cc/dotc/typer/Typer.scala#L4203
fn2(2)

@i10416
Copy link
Contributor Author

i10416 commented Jan 26, 2024

It seems expected behavior to raise error rather than warning 🤔

Disallow lambdas in statement position or if the expected type is Unit. This was a loophole that opened up due to the changes to eta expansion in Scala 3. We did get a warning in these cases, but an error might be better.

#11769

@i10416 i10416 closed this Jan 26, 2024
@i10416 i10416 reopened this Jan 26, 2024
@i10416 i10416 changed the title draft:fix(#19266): inconsistent compile error for eta-expanded methods draft:fix(#19266): better error for impure lambda in stmt position Jan 26, 2024
@i10416 i10416 force-pushed the fix/19266-inconsistency-in--method-with-params-list branch from 2645e36 to a953135 Compare January 26, 2024 20:17
synthetic lambda generated from a method with default arguments skipped
through `closureDef` pattern.
@i10416 i10416 changed the title draft:fix(#19266): better error for impure lambda in stmt position draft:fix(#19266): better warning for impure lambda in stmt position Jan 26, 2024
@i10416 i10416 changed the title draft:fix(#19266): better warning for impure lambda in stmt position fix(#19266): better warning for impure lambda in stmt position Jan 26, 2024
@i10416 i10416 changed the title fix(#19266): better warning for impure lambda in stmt position fix(#19266): better warning for impure synthetic lambda in stmt position Jan 26, 2024
Before this commit, closureDef matching rule was not strict enough to
filter out false-positive cases, which caused
tests/init-global/neg/return2.scala to fail.

It matched a plain block with ValDef at the begining and Closure at
the end.

As we want to match `closureDef` against a closure or a synthetic closure generated from
eta-expansion, we should match against

1. a simple closure
2. a closure inside nested blocks without statement, which is generated from eta-expansion on method without
   default arguments
3. a closure inside nested blocks with statements consisting only of ValDef of
   default arguments, which is generated from eta-expansion on
   method with default arguments.

This commit adds the condition for 3.
@i10416 i10416 marked this pull request as ready for review January 27, 2024 12:36
@dwijnand
Copy link
Member

@i10416 before I merge this, what's the status from your perspective? Are your commits here ready to be merged?

I think your arguments are very reasonable and your changes are right. But I wonder if it would be better to be bolder to make things simpler. I'm thinking we make closures not compile, whether they're pure or not. Using your example:

import scala.collection.mutable.ArrayBuffer
import scala.util.Random

val arr = ArrayBuffer[Int]()

def fn0(x: Int)(y: String) = Some(s"$x,$y")
def fn1(x: Int, y: Unit = arr.addOne(Random.nextInt()))(z:Double) = Some(s"$x,$y,$z")
def fn2(x: Int, y: => Unit = arr.addOne(Random.nextInt()))(z:Double) = Some(s"$x,$y,$z")

// Thus, it shouldn't compile.
fn0(0)
// This is impure(perform side-effect to mutate `arr`). Therefore this passes through `checkStatementPurity`.
fn1(1)
// This is pure and falls into the missing argument arm 
// https://github.com/lampepfl/dotty/blob/1716bcd9dbefbef88def848c09768a698b6b9ed9/tests/pos-with-compiler-cc/dotc/typer/Typer.scala#L4203
// Thus, it shouldn't compile.
fn2(2)

I think we should just also make fn1(1) not compile, even if it has a side-effecting default by-value argument. Any project that crucially relies on that effect can do one of many workarounds to preserving their program's behaviour. What do you think?

@i10416
Copy link
Contributor Author

i10416 commented Feb 23, 2024

@dwijnand

Are your commits here ready to be merged?

Yes, it is ready for review.

I think we should just also make fn1(1) not compile, e.....

I agree with you. It feels consistent to prohibit lambda in statement position regardless of its purity.
I guess few projects if any depend on this behavior and preserving similar behavior is not so hard.

I will add some changes and let's see whether any projects in the community build fail to compile.

@dwijnand
Copy link
Member

@odersky do you agree to prohibiting more lambdas, extending #11769?

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.

I think if we want an unused warning (which for me is debatable, but I am I know little about unused policy and am generally considered a skeptic), we need to implement this in the linting phase, not typer. That phase could use a new extractor similar to clsoureDef, but it should not change the original closureDef extractor.

In particular, we should not change fundamental abstractions like a closureDef extractor just because it is more convenient for some lint. The risk this breaks something in type inference or code generation is far too big.

if isPure then
missingArgs(tree, tree.tpe.widen)
else
report.warning(UnusedNonUnitValue(tree.tpe),original.srcPos)
Copy link
Contributor

@odersky odersky Feb 23, 2024

Choose a reason for hiding this comment

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

A UnusedNonUnitValue warning would have to be issued by the linter. It's a conscious design decision not to mix typing and linting one one phase. Typing is mandatory, linting is optional. Typing is precise according to spec, whereas linting is ad-hoc and not specified by the language.

Copy link
Contributor

Choose a reason for hiding this comment

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

FWIW, this is a challenge in scala 2, which is more constrained. Typer has information you want to exploit which may not be available later, but you don't want to bloat or break typer; scala 2 resorts to attachments. In this case, I don't care about purity, -Wnonunit-statement should always warn. Maybe it won't issue a duplicate warning if the positions are the same.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

this is a challenge in scala 2, ... Typer has information you want to exploit which may not be available later

I wonder we can take advantage of Tasty information for linting. I guess the difficulty would be some linting might depend on compiler state that would not be available from Tasty.

@@ -790,10 +790,13 @@ trait TypedTreeInfo extends TreeInfo[Type] { self: Trees.Instance[Type] =>

/** An extractor for def of a closure contained the block of the closure. */
object closureDef {
private def isDefaultArg(tree:Tree): Boolean = tree match
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think we should mix default arguments with this extractor. I prefer to leave it as it was before.

Copy link
Contributor Author

@i10416 i10416 Feb 24, 2024

Choose a reason for hiding this comment

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

@odersky

just because it is more convenient for some lint. ...
#19540 (review)

I also cared about it and when I added isDefaultArg, I thought Block(Nil, expr) here is the special case for closure where default argument stmts is empty, therefore this change would be safe.

Do you think the // closure 1 in the following example is not closure?

Block(Nil, expr)

{
  {
      // closure 0
  }
}

Block(stmts, expr)

{
  // These are synthesized ValDefs from default arguments invisible to users.
  val meth$default0 = ???
  val meth$default1 = ???
  // ...
  {
     // closure 1
  } 

}

Copy link
Contributor

Choose a reason for hiding this comment

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

I understand, but I don't think we should generalize closureDef that way.

I also cared about it and when I added isDefaultArg, I thought Block(Nil, expr) here is the special case for closure where default argument stmts is empty, therefore this change would be safe.

That's one of the scenarios. But the compiler is also free to wrap closureDef in a block in other situations.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sure, I will change it back. Thank you for feedback.

What do you think about prohibiting impure lambda in statement position?

#19540 (comment)

Copy link
Contributor

Choose a reason for hiding this comment

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

We could try that and see what breaks. The comments on #11769 indicate that this won't be easy, though.

@dwijnand
Copy link
Member

@odersky do you agree to prohibiting more lambdas, extending #11769?

@odersky asides from the extractor changes, which can be changed back, what do you think about making some more lambdas type errors, like #11769 did?

@dwijnand
Copy link
Member

dwijnand commented Mar 3, 2024

Needs a rebase, for the new onnx submodule version.

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.

Undeterministic eta expansion for a method with default arguments
4 participants