Skip to content

Fix inferred capture-polymorphic function literals #25936

Closed
bracevac wants to merge 5 commits intoscala:mainfrom
dotty-staging:ob/fix-25830-v2
Closed

Fix inferred capture-polymorphic function literals #25936
bracevac wants to merge 5 commits intoscala:mainfrom
dotty-staging:ob/fix-25830-v2

Conversation

@bracevac
Copy link
Copy Markdown
Contributor

@bracevac bracevac commented Apr 27, 2026

Fixes #25830

Problem

For a value val f = { [C^] => (xs: List[File^{C}]) => xs }, the typer
correctly infers [C^] => List[File^{C}] => List[File^{C}], but PostTyper's
CleanupRetains erased the {C} retains because they weren't recognized as
stable references. After erasure the lambda lost its capture polymorphism and
calls like f[{x}](files) failed.

Approach

CleanupRetains now preserves a retain element if it is one of:

  • a capset TypeParamRef of a TypeLambda we're inside (own binder),
  • a capset TypeRef aliasing such a binder (synthetic class type members
    surfaced by curried literals on inner-fragment visits),
  • caps.any only inside a capset bound (the synthesized default upper
    bound <: caps.CapSet^{any} for [C^]).

Anything else is erased to Nothing as before. When CleanupRetains is
applied to the inferred tpt of a user-written capture-polymorphic literal,
erasure additionally raises an implementation-restriction error.

Detection of "literal RHS"

PostTyper attaches a sticky IsCapsetPolyFunLiteralTpt marker to the
inferred tpt of a val/def whose RHS is a capset poly-fn literal,
detected via tpd.closureDef plus a PolyType-with-capset-binder check on
the synthesized $anonfun. CleanupRetains enables the restriction error
only when this attachment is present, which avoids false positives for
inferred poly-fn types coming from method calls or aliases.

Supported shapes

The restriction is basically

  1. Have at most one binder sequence binding capset variables [C^, D^,...] in a curried lambda.
  2. For parameters: Capture references in user-written capture sets can only refer to bound capset variables of the lambda literal.
  3. Capture-set bounds in said [C^, D^,...] block can refer to any, or the bound C, D,... capset variables in that block.

Examples:

  • { [C^] => xs => xs }
  • { [C^, D^ <: {C}, E^ >: {C} <: {C, D}] => (xs, ys) => (zs, ws) => () }
  • { [C^] => xs => ys => body } (curried via Function1)
  • def d = { [C^] => xs => xs }
  • val f: T = { [C^] => xs => xs } (explicit ascription is silent)
  • Inside anonymous functions: files.map(file => { [C^] => xs => xs })

Rejected shapes (implementation restriction)

  • Term refs in value-position retains: [C^] => (xs: List[File^{C, external}])
  • Term refs in capset bounds: [C^, D^ <: {C, external}]
  • caps.any in value-position retains
  • Nested capset binders: [C^] => xs => [D^] => ys (the cc machinery's
    SubstBindingMap over Vars containing outer-binder refs would otherwise
    crash later during Setup; see comment in CaptureOps)

For these the user can fall back to a class with a polymorphic apply method,
which is shown as a workaround in the new i25830-apply-workaround.scala
test.

Files changed

  • cc/CaptureOps.scala — rewrote CleanupRetains (TypeMap with
    capSetBinders tracking, isPreserved check, restriction reporting).
  • cc/Setup.scala — accept capset TypeRef alongside ParamRef in
    isRetainedParamRef so curried literals' synthetic class type members
    flow through Setup correctly.
  • transform/PostTyper.scala — added IsCapsetPolyFunLiteralTpt sticky
    attachment + isCapsetPolyFunLiteralRhs helper; sets the attachment on
    val/def tpts whose RHS is a literal.
  • New tests:
    • tests/pos-custom-args/captures/i25830.scala (plain, curried,
      def-bound, anonymous-function-context)
    • tests/pos-custom-args/captures/i25830-bounded.scala (bounded
      capset binders, curried-bounded)
    • tests/pos-custom-args/captures/i25830-apply-workaround.scala (the
      class-with-apply workaround)
    • tests/neg-custom-args/captures/i25830-unsupported.scala (every
      rejected shape with // error annotations)
  • Removed tests/pos-custom-args/captures/cap-paramlists{3,6,7,8}.scala
    these were parser-only tests using outer-term refs in capset bounds and
    no longer compile under the new restriction. The same parser coverage
    exists in cap-paramlists.scala (def-based) and
    cap-paramlist8-desugared.scala (explicit ascription).

Known limitations

  • Truly interleaved capset binders ([C^] => xs => [D^] => ys) are now
    rejected with a restriction error rather than crashing in cc — but
    ideally the cc machinery would handle them; tracked separately.

Originally added in scala#24560 to strip inferred retains from inlined call
trees before they reach the pickler. The cc setup pipeline now handles
retains correctly without this preemptive strip, and removing it
leaves scala3-bootstrapped/testCompilation green.
Fixes scala#25830. For `val f = { [C^] => (xs: List[File^{C}]) => xs }`, the
typer now produces a stable inferred type whose `{C}` retains survive
PostTyper, so callers like `f[{x}](files)` work as expected.

Implementation:

* `cc/CaptureOps.scala`: rewrote `CleanupRetains` as a `TypeMap` that
  preserves a retain element only when it's (a) a capset `TypeParamRef`
  of an in-scope TypeLambda, (b) a capset `TypeRef` aliasing such a
  binder (synthetic poly-fn class type members surfaced when curried
  literals are visited as inner fragments), or (c) `caps.any` inside a
  capset bound. Anything else is erased to `Nothing` as before.
* `transform/PostTyper.scala`: added `IsCapsetPolyFunLiteralTpt` sticky
  attachment plus an `isCapsetPolyFunLiteralRhs` helper based on
  `tpd.closureDef`. PostTyper attaches the marker to the inferred tpt
  of any val/def whose RHS is a capset poly-fn literal. CleanupRetains
  reads it to enable an implementation-restriction error precisely on
  user-written literals (not on inferred poly-fn types from method
  results or aliases).
* `cc/Setup.scala`: accept capset `TypeRef` alongside `ParamRef` in
  `isRetainedParamRef` so curried literals' synthetic class type
  members flow through Setup correctly.

Rejected shapes (with a clear restriction error):

* term refs in retains (value-position or capset-bound),
* `caps.any` in non-bound positions,
* nested capset binders like `[C^] => x => [D^] => y` (which would
  otherwise crash later in cc's `SubstBindingMap`).

Tests:

* tests/pos-custom-args/captures/i25830.scala: plain, curried,
  def-bound, and anonymous-function-context literals.
* tests/pos-custom-args/captures/i25830-bounded.scala: bounded capset
  binders, curried-bounded.
* tests/pos-custom-args/captures/i25830-apply-workaround.scala: the
  class-with-polymorphic-apply workaround for shapes outside the
  supported form.
* tests/neg-custom-args/captures/i25830-unsupported.scala: each
  rejected shape, with `// error` annotations.
* Removed tests/pos-custom-args/captures/cap-paramlists{3,6,7,8}.scala:
  these were parser-only tests using outer-term refs in capset bounds.
  Equivalent parser coverage remains in cap-paramlists.scala (def-based)
  and cap-paramlist8-desugared.scala (explicit ascription).
Recurse `isCapsetPolyFunLiteralRhs` through closure bodies whose
synthesized `$anonfun` is non-polymorphic, so a literal nested inside a
Function1 like `(i: Int) => { [C^] => ... }` is recognized as a literal
RHS. Previously such cases silently erased their retains; now the
implementation restriction fires when retains reference non-binder refs.

Add positive (clean nested literal) and negative (nested literal with
external dep) tests.
// Capset param of an in-scope TypeLambda.
ref.derivesFromCapSet && capSetBinders.exists(_ eq ref.binder)
case ref: TypeRef =>
// Capset typedef aliasing an in-scope binder — for curried literals.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I believe this is dead code. paramRefs returns either a list of TermParamRefs or of TypeParamRefs. Neither can equal a TypeRef. I guess the case can simply be dropped.

else
val elems = annot.retainedType.retainedElementsRaw
val keep = elems.nonEmpty && elems.forall(isPreserved(_, parent))
// Suppress only the narrow case where the entire retain is a single
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
// Suppress only the narrow case where the entire retain is a single
// Suppress only the narrow case where the entire retains is a single

def innerApply(tp: Type) =
val tp1 = tp match
case AnnotatedType(parent, annot: RetainingAnnotation) =>
val elems = annot.retainedType.retainedElementsRaw
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The treatment here assumes that we have cleaned up the type with CleanupRetains before. But that's not true if the type arose from inlining, since we just dropped CleanupRetains there. We probably need to bring back CleanupRetains for inlined code.

val parent1 = apply(parent) match
case CapturingType(p, refs) if refs.elems.isEmpty && !refs.isConst => p
case other => other
CapturingType(parent1, CaptureSet(caps*))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This might be too simplistic. There are a lot of subtleties and corner cases whan mapping a retains to a capture set. We should use the same code as in transformExplicitType for this.

* restriction error: it should fire only on user-written literals, not on
* inferred poly-fn types that come from method calls or aliases.
*/
val IsCapsetPolyFunLiteralTpt: util.Property.StickyKey[Unit] = util.Property.StickyKey()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We don't need a StickyAttachment here. It's a local property for PostTyper traversals. In fact, we don't need an attachment at all. A local set maintained by PostTyper is good enough. See last commit.

@bracevac
Copy link
Copy Markdown
Contributor Author

Superseded by #25939

@bracevac bracevac closed this Apr 27, 2026
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.

CC: Type inference for capture-polymorphic lambdas is broken

2 participants