Modularity Improvements
Modularity Improvements
Martin Odersky, 7.1.2024
Scala is a language in the SML tradition, in the sense that it has abstract and alias types as members of modules (which in Scala take the form of objects and classes). This leads to a simple dependently typed system, where dependencies in types are on paths instead of full terms.
So far, some key ingredients were lacking which meant that module composition with functors is harder in Scala than in SML. In particular, one often needs to resort the infamous Aux
pattern that lifts type members into type parameters so that they can be tracked across class instantiations. This makes modular, dependently typed programs much harder to write and read, and makes such programming only accessible to experts.
In this note I propose some small changes to Scala's dependent typing that makes modular programming much more straightforward.
The suggested improvements have been implemented and are available in source version future
if the additional experimental language import modularity
is present. For instance, using the following command:
scala compile -source:future -language:experimental.modularity
Tracked Parameters
Scala is dependently typed for functions, but unfortunately not for classes. For instance, consider the following definitions:
class C:
type T
...
def f(x: C): x.T = ...
val y: C { type T = Int }
Then f(y)
would have type Int
, since the compiler will substitute the concrete parameter reference y
for the formal parameter x
in the result type of f
, and y.T = Int
However, if we use a class F
instead of a method f
, things go wrong.
class F(val x: C):
val result: x.T = ...
Now F(y).result
would not have type Int
but instead the rather less useful type ?1.T
where ?1
is a so-called skolem constant of type C
(a skolem represents an unknown value).
This shortcoming means that classes cannot really be used for advanced modularity constructs that rely on dependent typing.
Proposal: Introduce a tracked
modifier that can be added to a val
parameter of a class or trait. For every tracked class parameter of a class C
, add a refinement in the constructor type of C
that the class member is the same as the parameter.
Example: In the setting above, assume F
is instead declared like this:
class F(tracked val x: C):
val result: x.T = ...
Then the constructor F
would get roughly the following type:
F(x1: C): F { val x: x1.type }
Aside: More precisely, both parameter and refinement would apply to the same name x
but the refinement still refers to the parameter. We unfortunately can't express that in source, however, so we chose the new name x1
for the parameter in the explanation.
With the new constructor type, the expression F(y).result
would now have the type Int
, as hoped for. The reasoning to get there is as follows:
- The result of the constructor
F(y)
has typeF { val x: y.type }
by the standard typing for dependent functions. - The type of
result
insideF
isx.T
. - Hence, the type of
result
as a member ofF { val x: y.type }
isy.T
, which is equal toInt
.
The addition of tracked parameters makes classes suitable as a fundamental modularity construct supporting dependent typing. Here is an example, taken from issue #3920:
trait Ordering:
type T
def compare(t1:T, t2: T): Int
class SetFunctor(tracked val ord: Ordering):
type Set = List[ord.T]
def empty: Set = Nil
extension (s: Set)
def add(x: ord.T): Set = x :: remove(x)
def remove(x: ord.T): Set = s.filter(e => ord.compare(x, e) != 0)
def contains(x: ord.T): Boolean = s.exists(e => ord.compare(x, e) == 0)
object intOrdering extends Ordering:
type T = Int
def compare(t1: T, t2: T): Int = t1 - t2
val IntSet = new SetFunctor(intOrdering)
@main def Test =
import IntSet.*
val set = IntSet.empty.add(6).add(8).add(23)
assert(!set.contains(7))
assert(set.contains(8))
This works as it should now. Without the addition of tracked
to the parameter of SetFunctor
typechecking would immediately lose track of the element type T
after an add
, and would therefore fail.
Syntax Change
ClsParam ::= {Annotation} [{Modifier | ‘tracked’} (‘val’ | ‘var’)] Param
The (soft) tracked
modifier is only allowed for val
parameters of classes.
Discussion
Since tracked
is so useful, why not assume it by default? First, tracked
makes sense only for val
parameters. If a class parameter is not also a field declared using val
then there's nothing to refine in the constructor result type. One could think of at least making all val
parameters tracked by default, but that would be a backwards incompatible change. For instance, the following code would break:
case class Foo(x: Int)
var foo = Foo(1)
if someCondition then foo = Foo(2)
If we assume tracked
for parameter x
(which is implicitly a val
), then foo
would get inferred type Foo { val x: 1 }
, so it could not be reassigned to a value of type Foo { val x: 2 }
on the next line.
Another approach might be to assume tracked
for a val
parameter x
only if the class refers to a type member of x
. But it turns out that this scheme is unimplementable since it would quickly lead to cyclic references when typechecking recursive class graphs. So an explicit tracked
looks like the best available option.
Allow Class Parents to be Refined Types
Since tracked
parameters create refinements in constructor types, it is now possible that a class has a parent that is a refined type. Previously such types were not permitted, since we were not quite sure how to handle them. But with tracked parameters it becomes pressing to admit such types.
Proposal Allow refined types as parent types of classes. All refinements that are inherited in this way become synthetic members of the class.
Example
class C:
type T
def m(): T
type R = C:
type T = Int
def m(): 22
class D extends R:
def next(): D
This code now compiles. The definition of D
is expanded as follows:
class D extends C:
def next(): D
/*synthetic*/ type T = Int
/*synthetic*/ def m(): 22
Note how class refinements are moved from the parent constructor of D
into the body of class D
itself.
This change does not entail a syntax change. Syntactically, parent types cannot be refined types themselves. So the following would be illegal:
class D extends C { type T = Int; def m(): 22 }: // error
def next(): D
If a refined type should be used directly as a parent type of a class, it needs to come in parentheses:
class D extends (C { type T = Int; def m(): 22 }) // ok
def next(): D
A Small Relaxation To Export Rules
The rules for export forwarders are changed as follows.
Previously, all export forwarders were declared final
. Now, only term members are declared final
. Type aliases are left aside.
This makes it possible to export the same type member into several traits and then mix these traits in the same class. The test file tests/pos/typeclass-aggregates.scala
shows why this is essential if we want to combine multiple givens with type members in a new given that aggregates all these givens in an intersection type.
The change does not lose safety since different type aliases would in any case lead to uninstantiatable classes.