SIP-52 - Binary APIs

Language

By: Author Nicolas Stucki

History

Date Version
Feb 27 2023 Initial Draft
Aug 16 2023 Single Annotation
Aug 24 2023 Change Annotation Name
Jan 09 2024 Change Overload Rules
Feb 29 2024 Experimental in Scala 3.4.0

Summary

The purpose of binary APIs is to have publicly accessible definitions in generated bytecode for definitions that are package private or protected. This proposal introduces the @publicInBinary annotation on term definitions and the -WunstableInlineAccessors linting flag.

Motivation

Provide a sound way to refer to private members in inline definitions

Currently, the compiler automatically generates accessors for references to private members in inline definitions. This scheme interacts poorly with binary compatibility. It causes the following three unsoundness in the system:

  • Changing any definition from private to public is a binary incompatible change
  • Changing the implementation of an inline definition can be a binary incompatible change
  • Removing final from a class is a binary incompatible change

You can find more details in https://github.com/lampepfl/dotty/issues/16983

Avoid duplication of inline accessors

Ideally, private definitions should have a maximum of one inline accessor, which is not the case now. When an inline method accesses a private/protected definition that is defined outside of its class, we generate an inline in the class of the inline method. This implies that accessors might be duplicated if a private/protected definition is accessed from different classes.

Removing deprecated APIs

There is no precise mechanism to remove a deprecated method from a library without causing binary incompatibilities. We should have a straightforward way to indicate that a method is no longer publicly available but still available in the generated code for binary compatibility.

- @deprecated(...) def myOldAPI: T = ...
+ private[C] def myOldAPI: T = ...

Related to discussion in https://github.com/lightbend/mima/discussions/724.

No way to inline reference to private constructors

It is currently impossible to refer to private constructors in inline methods.

class C private()
object C:
  inline def newC: C = new C() // Implementation restriction: cannot use private constructors in inline methods

If users want to access one of those, they must write an accessor explicitly. This extra indirection is undesirable.

class C private()
object C:
  private def newCInternal: C = new C()
  inline def newC: C = newCInternal

Proposed solution

High-level overview

This proposal introduces the @publicInBinary annotation, and adds a migration path to inline methods in libraries (requiring binary compatibility).

@publicInBinary annotation

A binary API is a definition that is annotated with @publicInBinary. This annotation can be placed on def, val, lazy val, var, object, and given definitions. A binary API will be publicly available in the bytecode.

This annotation cannot be used on private/private[this] definitions. With the exception of class constructors.

Removing this annotation from a non-public definition is a binary incompatible change.

Example:

class C {
  @publicInBinary private[C] def packagePrivateAPI: Int = ...
  @publicInBinary protected def protectedAPI: Int = ...
  @publicInBinary def publicAPI: Int = ... // warn: `@publicInBinary` has no effect on public definitions
}

will generate the following bytecode signatures

public class C {
  public C();
  public int packagePrivateAPI();
  public int protectedAPI();
  public int publicAPI();
}

In the bytecode, @publicInBinary definitions will have the ACC_PUBLIC flag.

Binary API and inlining

A non-public reference in an inline method is handled as follows:

  • if the reference is a @publicInBinary the reference is used;
  • otherwise, an accessor is automatically generated and used.

Example:

import scala.annotation.publicInBinary
class C {
  @publicInBinary private[C] def a: Int = ...
  private[C] def b: Int = ...
  @publicInBinary protected def c: Int = ...
  protected def d: Int = ...
  inline def foo: Int = a + b + c + d
}

before inlining the compiler will generate the accessors for inlined definitions

class C {
  @publicInBinary private[C] def a: Int = ...
  private[C] def b: Int = ...
  @publicInBinary protected def c: Int = ...
  protected def d: Int = ...
  final def C$$inline$b: Int = ...
  final def C$$inline$d: Int = ...
  inline def foo: Int = a + C$$inline$b + c + C$$inline$d
}
-WunstableInlineAccessors

In addition we introduce the -WunstableInlineAccessors flag to allow libraries to detect when the compiler generates unstable accessors. The previous code would show a linter warning that looks like this:

-- [E...] Compatibility Warning: C.scala -----------------------------
  |  inline def foo: Int = a + b + c + d
  |                            ^
  |            Unstable inline accessor C$$inline$b was generated in class C.
  |
  | longer explanation available when compiling with `-explain`
-- [E...] Compatibility Warning: C.scala -----------------------------
  |  inline def foo: Int = a + b + c + d
  |                                    ^
  |            Unstable inline accessor C$$inline$d was generated in class C.
  |
  | longer explanation available when compiling with `-explain`

When an accessor is detected we can tell the user how to fix the issue. For example we could use the -explain flag to add the following details to the message.

With `-WunstableInlineAccessors -explain` ~~~ -- [E...] Compatibility Warning: C.scala ----------------------------- | inline def foo: Int = a + b + c + d | ^ | Unstable inline accessor C$$inline$b was generated in class C. |----------------------------------------------------------------------------- | Explanation (enabled by `-explain`) |- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - | Access to non-public method b causes the automatic generation of an accessor. | This accessor is not stable, its name may change or it may disappear | if not needed in a future version. | | To make sure that the inlined code is binary compatible you must make sure that | method b is public in the binary API. | * Option 1: Annotate method b with @publicInBinary | * Option 2: Make method b public | | This change may break binary compatibility if a previous version of this | library was compiled with generated accessors. Binary compatibility should | be checked using MiMa. If binary compatibility is broken, you should add the | old accessor explicitly in the source code. The following code should be | added to class C: | @publicInBinary private[C] def C$$inline$b: Int = this.b ----------------------------------------------------------------------------- -- [E...] Compatibility Warning: C.scala ----------------------------- | inline def foo: Int = a + b + c + d | ^ | Unstable inline accessor C$$inline$d was generated in class C. |----------------------------------------------------------------------------- | Explanation (enabled by `-explain`) |- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - | Access to non-public method d causes the automatic generation of an accessor. | This accessor is not stable, its name may change or it may disappear | if not needed in a future version. | | To make sure that the inlined code is binary compatible you must make sure that | method d is public in the binary API. | * Option 1: Annotate method d with @publicInBinary | * Option 2: Make method d public | | This change may break binary compatibility if a previous version of this | library was compiled with generated accessors. Binary compatibility should | be checked using MiMa. If binary compatibility is broken, you should add the | old accessor explicitly in the source code. The following code should be | added to class C: | @publicInBinary private[C] def C$$inline$d: Int = this.d ----------------------------------------------------------------------------- ~~~

Specification

We must add publicInBinary to the standard library.

package scala.annotation

final class publicInBinary extends scala.annotation.StaticAnnotation

@publicInBinary annotation

  • Only valid on def, val, lazy val, var, object, and given.
  • If a definition overrides a @publicInBinary definition, it must also be annotated with @publicInBinary.
  • TASTy will contain references to non-public definitions that are out of scope but @publicInBinary. TASTy already allows those references.
  • The annotated definitions will be public in the generated bytecode. Definitions should be made public as early as possible in the compiler phases, as this can remove the need to create other accessors. It should be done after we check the accessibility of references.

Inline

  • Inlining will not require the generation of an inline accessor for binary APIs.
  • The user will be warned if a new inline accessor is automatically generated under -WunstableInlineAccessors. The message will suggest @publicInBinary and how to fix potential incompatibilities.

Compatibility

The introduction of the @publicInBinary do not introduce any binary incompatibility.

Using references to @publicInBinary in inline code can cause binary incompatibilities. These incompatibilities are equivalent to the ones that can occur due to the unsoundness we want to fix. When migrating to binary APIs, the compiler will show the implementation of accessors that the users need to add to keep binary compatibility with pre-publicInBinary code.

Other concerns

  • Tools that analyze inlined TASTy code will need to know about @publicInBinary. For example MiMa and TASTy MiMa.

Alternatives

Add a @binaryAccessor

This annotation would generate an stable accessor. This annotation could be used on private definition. It would also mitigate migration costs for library authors that have published unstable accessors.

  • Implementation https://github.com/lampepfl/dotty/pull/16992

Make all private[C] part of the binary API

Currently, we already make private[C] public in the binary API but do not have the same guarantees regarding binary compatibility. For example, the following change is binary compatible but would remove the existence of the private[C] definition in the bytecode.

class C:
-  private[C] def f: T = ...

We could change the rules to make all private[C] part of binary compatible to flag such a change as binary incompatible. This would imply that all these methods can be accessed directly from inline methods without generating an accessor.

The drawback of this approach is that that we would need to force users to keep their private[C] methods even if they never used inline methods.