In a previous thread Idiomatic Imports, I raised the problem that we lack a language-level mechanism to factor out repeated structure in the import section of Scala code.
I am foremost a developer of end-user applications in Scala. Building on top of ever richer and more complex library ecosystems, my experience is that managing the size and complexity of import clauses is becoming increasingly onerous. Imports are bloated with repetitive boilerplate that is highly correlated across source files in my apps.
A clear sign that imports are boilerplate is that the leading Scala IDE Intellij hides imports by default. The IDE assumes there’s little worth seeing in them, and it’s right!
Most of us write Scala not because our employer mandates it, but as an active choice to select a language that is concise, expressive, and equipped with good tools to factor out common structure. But in the import section, the available tools are falling short.
In the previous thread, my read was that Scala 3’s export
feature is proposed as the tool for collating groups of imports together into higher-level units. Currently export
has some significant limitations around wildcard exporting packages, and some bugs, but no “deal-breakers”, nothing that can’t be worked around temporarily.
So I went away and experimented and tried to make export
work. By “work” I mean, in an application codebase, the baseline imports are brought in with just a handful of imports of exporter objects, which themselves are full of exports of the underlying libraries.
Example exporter for the Coulomb library:
object CoulombSpire:
export coulomb.{/, `*`, ^}
export coulomb.syntax.{withUnit, withDeltaUnit}
object allalgebra extends algebra.instances.AllInstances
export allalgebra.{*, given}
export coulomb.ops.algebra.spire.all.{*, given}
export coulomb.policy.spire.standard.given
export coulomb.units.si.{*, given}
export coulomb.units.time.{Second as _, ctx_unit_Second as _, *, given}
export coulomb.units.si.prefixes.{*, given}
export coulomb.units.javatime.{*, given}
export coulomb.units.javatime.conversions.all.{*, given}
export coulomb.conversion.{UnitConversion, TruncatingUnitConversion}
//workaround https://github.com/lampepfl/dotty/issues/17151
type Quantity[V, U] = coulomb.Quantity[V, U]
type DeltaQuantity[V, B, U] = coulomb.DeltaQuantity[V, B, U]
Example application source file:
import CoulombSpire.{*, given}
BigDecimal("3.14").withUnit[Meter]
And it basically works. But I see a looming problem with how this scales. Poorly.
All those types and symbols that are being exported are generating large quantities of forwarding bytecode in CoulombSpire
(I’m seeing about 500kb per class in this example).
And what makes this worse, is we’ve only captured one particular import configuration. Coulomb has other combinations of imports for different situations, that would need to be collated into a different exporter, for more bytecode. And once we add in other libraries, Http4s for example, we’ll have more exporters for them, and the beginning of a combinatorial explosion in bytecode.
The Scala compiler is extremely adept at weaving bytecode. But the amount it emits is already problematic for runtime memory usage. And using export
to do import
will only push things further in the wrong direction.
An open Spire issue illustrates the practical impact of bytecode bloat. A user reported that when their JVM first hits the following line, the heap usage jumps by 450Mb (!), and intermittently trips an OutOfMemory error:
def identity[N:Fractional](a:N): N = a
The proposed explanation is simply that this line triggers the loading of a whole lot of Spire machinery & bytecode associated with the Fractional
typeclass & friends
So what’s an alternative to the current export behavior? I’ve considered a bunch of approaches including project/SBT-level compiler directives, and some def
-like syntax to declare aliases for groups of imports.
But I’ve come back to favoring @rssh earlier suggestion of a transparent export
mode:
Essentially, the transparent
modifier would create alias exports that have no runtime forwarding. They are purely a compile-time concept that changes what the compiler looks at when compiling. This solves the bytecode bloat problem and is also probably the most incremental enhancement from Scala today.