Proposing further improvement to modularity: modules without object identity

A note of appreciation of tracked parameters

First off, I welcome the introduction of tracked parameters, as described in Modularity Improvements.

Although the SetFunctor example given there might not be the best demonstration of their value,

Changing a single line in the example from

class SetFunctor(tracked val ord: Ordering):

to

class SetFunctor[Ord <: Ordering](val ord: Ord):

makes the example work without tracked parameters.

in my experiment I was able to obtain bigger simplifications:

Before

abstract class ScalettoStreams {
  type Dsl         <: Scaletto
  type PuroLib     <: libretto.puro.PuroLib[Dsl]
  type ScalettoLib <: libretto.scaletto.ScalettoLib[Dsl, PuroLib]
  type PuroStreams <: libretto.stream.puro.PuroStreams[Dsl, PuroLib]

  val dsl: Dsl
  val puroLib: PuroLib & libretto.puro.PuroLib[dsl.type]
  val scalettoLib: ScalettoLib & libretto.scaletto.ScalettoLib[dsl.type, puroLib.type]
  val underlying: PuroStreams & libretto.stream.puro.PuroStreams[dsl.type, puroLib.type]

  // ...
}

new ScalettoStreams {
  override type Dsl         = scaletto.type
  override type PuroLib     = lib.type
  override type ScalettoLib = sLib.type
  override type PuroStreams = pStreams.type

  override val dsl         = scaletto
  override val puroLib     = lib
  override val scalettoLib = sLib
  override val underlying  = pStreams
}

After

class ScalettoStreams(
  tracked val dsl: Scaletto,
  tracked val puroLib: PuroLib[dsl.type],
  tracked val scalettoLib: ScalettoLib[dsl.type, puroLib.type],
  tracked val underlying: PuroStreams.Of[dsl.type, puroLib.type],
) {
  // ...
}

new ScalettoStreams(scaletto, lib, sLib, pStreams)

Object identity as an impediment

Working with the SetFunctor example

trait Ordering:
  type T
  def compare(t1:T, t2: T): Int

class SetFunctor(tracked val ord: Ordering):
  // note that I changed Set to be opaque
  opaque type Set = List[ord.T]

  def empty: Set = Nil
  // ...

object intOrdering extends Ordering:
  type T = Int
  def compare(t1: T, t2: T): Int = t1 - t2

val IntSet = new SetFunctor(intOrdering)

the type IntSet.Set is (only formally) dependent on object identity of IntSet, which means that, given

val IntSet1 = new SetFunctor(intOrdering)
val IntSet2 = new SetFunctor(intOrdering)

the types IntSet1.Set and IntSet2.Set are formally distinct, even though they are actually the same type List[intOrdering.T].

That’s old news, but …

Why is it an impediment?

It becomes problematic when multiple modules instantiate their own copy of SetFunctor:

class Foo(tracked val ord: Ordering):
  val set = SetFunctor(ord)

class Bar(tracked val ord: Ordering):
  val set = SetFunctor(ord)

Now, even when Foo and Bar are instantiated with the same Ordering instance, their respective Sets are not interoperable:

val foo = Foo(intOrdering)
val bar = Bar(intOrdering)

val xs: foo.set.Set = bar.set.empty // ERROR
[error] Found:    bar.set.Set
[error] Required: foo.set².Set
[error] 
[error] where:    set  is a value in class Bar
[error]           set² is a value in class Foo
[error] val xs: foo.set.Set = bar.set.empty
[error]                       ^^^^^^^^^^^^^

Existing solution snowballs quickly

One might argue that if interoperability of Foo’s and Bar’s Set is needed, they should take the SetFunctor as a parameter:

object SetFunctor:
  type Of[Ord <: Ordering] = SetFunctor { val ord: Ord }

class Foo(
  tracked val ord: Ordering,
  tracked val set: SetFunctor.Of[ord.type],
)

class Bar(
  tracked val ord: Ordering,
  tracked val set: SetFunctor.Of[ord.type],
)

and then be instantiated with the same SetFunctor instance:

val intSet = SetFunctor(intOrdering)

val foo = Foo(intOrdering, intSet)
val bar = Bar(intOrdering, intSet)

val xs: foo.set.Set = bar.set.empty // OK

This is indeed the approach I use.

as illustrated by this snippet from above:
class ScalettoStreams(
  tracked val dsl: Scaletto,
  tracked val puroLib: PuroLib[dsl.type],
  tracked val scalettoLib: ScalettoLib[dsl.type, puroLib.type],
  tracked val underlying: PuroStreams.Of[dsl.type, puroLib.type],
)

(The mixture of type-parameterization (PuroLib, ScalettoLib) and Aux pattern (PuroStreams.Of) is unimportant.)

However, it

  • leads to bloated dependent parameter lists,

  • requires the Aux pattern or heavy type parameterization,

  • and thus effectively deters from more granular modularization.

    For example, PuroLib above has 4000+ LoC, but I hesitate to split it to avoid explosion of these parameter lists (taking like 7 different parameters instead of PuroLib; and that is for each of ScalettoLib, PuroStreams, ScalettoStreams).

Solution: Modules without object identity

Recall that in

class SetFunctor(tracked val ord: Ordering):
  opaque type Set = List[ord.T]

the type Set depends only on the parameter ord and not on the identity of SetFunctor. This is a very common case, at least for me.

For example, my

class PuroLib(val dsl: DSL)

defines many types, e.g.

Maybe
Optionally
Unlimited
Multiple
LList
LList1
Endless
Lease
Lock
...

which depend only on dsl, not on PuroLib’s object identity.

I’d like to propose modules without object identity:

module SetFunctor(ord: Ordering):
  opaque type Set = List[ord.T]

where

  • all member types (incl. class types (not shown here)) of a module depend only on module’s (type- and value-) parameters;
  • that is, the compiler infers that
    SetFunctor(x).Set =:= SetFunctor(x).Set
  • all constructor parameters are automatically tracked vals;
  • the exact syntax (module keyword) is unimportant at this point.

Application

With the new SetFunctor module, the original version of our previous example would just work

class Foo(tracked val ord: Ordering):
  val set = SetFunctor(ord)

class Bar(tracked val ord: Ordering):
  val set = SetFunctor(ord)

val foo = Foo(intOrdering)
val bar = Bar(intOrdering)

// this is now OK
val xs: foo.set.Set = bar.set.empty

because the type of both foo.set and bar.set is a (pure) function of the same intOrdering. Let’s denote this type as SetFunctor(intOrdering.type). We have:

foo.set : SetFunctor(intOrdering.type)
bar.set : SetFunctor(intOrdering.type)

// therefore
foo.set.Set =:= SetFunctor(intOrdering.type)#Set
bar.set.Set =:= SetFunctor(intOrdering.type)#Set

// therefore
foo.set.Set =:= bar.set.Set

In closing

I believe (and tried to demonstrate) that the proposed feature greatly improves the experience of writing modular code in Scala, thereby opening doors to things that would previously be prohibitively cluttered.

I do realize that my proposal is far from being a specification, but I do hope it is sufficiently clear for people to contemplate and scrutinize.

I also realize that it would require non-trivial changes to the type-checker (treating module names as uninterpreted functions).

Anyway, encouraged by the recent (still experimental) improvements to modularity, I am hoping this effort will continue, including in a direction that addresses the impediment outlined in this post.

4 Likes

I need to read through the linked things still, but isn’t the problem at the core the path dependent typing of the type members?

If there were no path dependent typing something like

foo.set.Set =:= bar.set.Set

were trivially true.

I guess this will now likely sound extremely controversial, but how about making path dependent typing optional?

This feature is only very seldom needed, it’s extremely confusing to newcomers (when you come from any other language it’s a major puzzler that foo.set.Set =:= bar.set.Set does not compile), and it mostly only complicates type checking even when not used.

How about putting path dependent types, as one of the most advanced Scala features, behind a language import?

I’m not a big fan of language imports in general, but for something like path dependent types it would make sense imho: You don’t want this super complex and often confusing feature in “normal code” (especially if the code wants to be lean and understandable by everyone).

There are now language imports (and planed extra syntactical overhead) for much more mundane features like conversions, something that is actually very simple in comparison.

For even most basic things like infix methods there are plans to guard them by extra explicit syntax. In that light it looks quite questionable that something as complex and advanced, and at the same time super rare, as dependent typing is on by default and does not require any extra ceremony to kick in.

So wouldn’t it make sense to make path dependent types in general an opt-in feature?

Could such a change solve this case here also?

Even if it doesn’t help with this here, wouldn’t it make sense to consider such simplification of the language in general?

I’m not saying to get rid of path dependent types. That’s likely impossible. The point is to turn them off by default, and make them opt-in for the seldom cases where one really needs them.

I believe this is known as an applicative functor in OCaml. One problem here is that this works only if the functor construction is referentially transparent, and so far we don’t have a very expressive framework to check that. Maybe if capture checking has matured so that it checks all sorts of side effects.

The workaround is, as you suggest, explicit constraints that assert equality of type class witnesses. I am not sure the solution you present is the last word here yet. Could refinement types mentioning tracked parameters give better ergonomics? I am not sure, just speculating.

Indeed, thanks for a reference to established terminology.

Good point.

I think it is problematic for type soundness only when types depend on values.

For starters, maybe it would suffice to restrict which values are admissible to be captured by types (without having to track side-effects).

Let’s say a value v is admissible to be captured by module M’s member type T, if

  • v is captured by M; or
  • v is M’s parameter; or
  • v is itself a module created by application to admissible values.

Example:

module Foo(ord: Ordering):
  val set  = SetFunctor(ord)
  val set1 = SetFunctor(new Ordering { /* ... */})

  opaque type T = ord.T    // OK: `ord` is a module parameter
  opaque type S = set.Set  // OK: `set` is a module created from admissible values
  opaque type R = set1.Set // ERROR: `set1` created from non-admissible value

  // non-admissible values
  val x = scala.util.Random.nextInt()
  val y = 7 // may become admissible once we have side-effect tracking

  opaque type I = x.type // ERROR: `x` is not admissible

  // ERROR: `x` is not admissible to be captured by `Foo`'s member type `Inner`
  module Inner(s: String):
    def getInt: Int = x

Sure. It is more important to me to solve the presented problem rather than to implement the presented solution.

Not sure what you envision, can you elaborate?

1 Like

I might be in favor of path-dependence being opt-in, but

  • I don’t think it’s realistic as it would break everything;
  • I want Foo(1)#T and Foo(2)#T to still be different types (short of any further knowledge about T);
  • Java’s inner classes are path-dependent, too;
  • as @odersky pointed out, side-effects in constructors ruin everything (even in my proposal). In my reply to him I proposed some restrictions for values that are admissible to be captured by types. I can’t imagine putting such restrictions on existing code, but feasible to introduce them for the new module construct.
1 Like

If we had a guaranteed memoization mechanism (something like @memo class Foo(...) expanding into a a memoized apply method calling a private constructor), we wouldn’t have to worry about side-effects in the construction so much. The downside of guaranteed memoization is that the corresponding class instances can only be GC’ed when the corresponding classloader is GC’ed, so memory usage becomes harder to manage.

LOL, I’m an idiot. :joy:

I should have refrained from commenting before reading the reference material…

The whole point of this “modularity” proposal is to improve the usability of dependent types, and extend them from methods / functions to constructors.

So hiding dependent types makes no sense whatsoever in context of this proposal!

That’s actually OK, as like said, I think hiding language features behind imports is not a good idea in most cases anyway.

If this “modularity” proposal makes dependent types more symmetric and simpler to use for the more complex (and therefore “scary”) use-cases that’s for sure for the better. I welcome that!

Wouldn’t the “natural expectation” be anyway that a module lives for a 'static lifetime? (Or, as the JVM is dynamic, as long as the subsystem it belongs to is active).

I’m having trouble getting convinced that the original example is uncovering new motivations. We are invited to note that Set member has been defined as an opaque type alias, about which Scala’s documentation has the following to say:

Instead of manually splitting our [SetFunctor] into an abstract part and into a concrete implementation, we can simply use opaque types in Scala 3 to achieve a similar effect. […] outside of [SetFunction], the type of [Set] is completely encapsulated, or “opaque.”

We should observe that the opaque type member is equivalent to an abstract type member outside of SetFunctor’s scope. Further, it is my understanding that equality of abstract type member selections (i.e., selections of the form t.M where M cannot be de-aliased) can only be concluded from equal prefixes unless additional evidences are in context. For example:

class A { type M }

val a0: A = new A { type M = Int }
val a1: A = new A { type M = Int }

def f0: a0.M = ???
def f1: a1.M = f0 // error: found 'a0.M', required 'a1.M'

The following wouldn’t work either:

val a2: A = a0
def f2: a2.M = f0 // error: found 'a0.M', required 'a2.M'

A key point of path-dependent types is precisely that two abstract type members prefixed by different instances are different. Hence, from where I stand, it seems the error reported for the original example is expected and desired, just like the one reported in the above snippet.

There’s a simpler way to achieve the desired outcome if we give up on opaque (i.e., abstract) types, as the OP’s first example suggests. It even scales as the application grows.

Example without abstract type members
trait Ordering {
  type T
  def compare(t1:T, t2: T): Int
}

class SetFunctor[Ord <: Ordering](val ord: Ord) {
  type NotOpaqueSet = List[ord.T] 
}

class Foo[Ord <: Ordering](val ord: Ord) {
  val set = SetFunctor(ord)
}
class Foo[Ord <: Ordering](val ord: Ord) {
  val set = SetFunctor(ord)
}

object IntOrdering extends Ordering {
  type T = Int
  def compare(t1: T, t2: T): Int = t1 - t2
}

val foo = Foo(IntOrdering) // inferred type is Foo[IntOrdering.type]
val bar = Bar(IntOrdering)

def f0: foo.set.NotOpaqueSet = ???
def f1: bar.set.NotOpaqueSet = f0 // OK

To keep Set abstract, the different solutions that have been proposed essentially boil down to threading the prefix through the type system, which can be achieved without the “Aux” pattern or additional dependent parameters, the snowballing issues mentioned by OP. The following illustrates (the minimization should be credited to @EugeneFlesselle):

import scala.language.experimental.modularity

trait Ordering {
  type T
  def compare(t1:T, t2: T): Int
}

class SetFunctor(tracked val ord: Ordering) {
  opaque type Set = List[ord.T]
}

class Foo(tracked val set: SetFunctor)
class Bar(tracked val set: SetFunctor)

object IntOrdering extends Ordering {
  type T = Int
  def compare(t1: T, t2: T): Int = t1 - t2
}
object IntSet extends SetFunctor(IntOrdering)

val foo = Foo(IntSet)
val bar = Bar(IntSet)

def f0: foo.set.Set = ???
def f1: bar.set.Set = f0 // OK
Equivalently without 'tracked'
trait Ordering {
  type T
  def compare(t1:T, t2: T): Int
}

class SetFunctor[Ord <: Ordering](val ord: Ord) {
  opaque type Set = List[ord.T]
}

class Foo[Set <: SetFunctor[?]](val set: Set)
class Bar[Set <: SetFunctor[?]](val set: Set)

object IntOrdering extends Ordering {
  type T = Int
  def compare(t1: T, t2: T): Int = t1 - t2
}

object IntSet extends SetFunctor(IntOrdering)
val foo = Foo(IntSet)
val bar = Bar(IntSet)

def f0: foo.set.Set = ???
def f1: bar.set.Set = f0 // OK

The issue is that when an abstract type member is involved, then it always depends on the prefix of the selection, which is a value. In the original example, the fact that the definition of Set depends on ord, a parameter of SetFunctor, is irrelevant. That is evidenced by the example without abstract type members, which type checks.

If abstraction is desired, then we should also expect two member selections with different prefixes to be different unless additional equality evidence is provided, as @odersky has suggested. This feature is also desirable in the context of type-class oriented programming, where generic algorithms are described under constraints between associated types. However, absent of other constraints (e.g., coherence), such an inference is incorrect for the same reasons as previously discussed.

For example, Scala will rightfully complain about different prefixes in the following program:

import scala.language.experimental.modularity

trait Collection { type Self; type Slice: Collection }

def foo[
  T: Collection as T,
  U: Collection { type Slice = T.Slice } as U
](
  x: T.Slice.Slice
): U.Slice.Slice = x // error: found '(x : T.given_Coll_Slice.Slice)', required 'U.given_Coll_Slice.Slice'

Supporting this kind of reasoning is certainly desirable but would require much more significant changes to the type system, going beyond the syntactic improvements brought by modularity.

To illustrate the snowballing in your changed example, try implementing another module Qux that uses Foo, Bar and SetFunctor, where all three Set types (foo.set.Set, bar.set.Set, set.Set) are the same.

I didn’t imagine modularity improvements to be limited to syntactic conveniences.

2 Likes

Sure, do you mean as in the following?

... // same as the changed example

class Qux(tracked val set: SetFunctor, tracked val foo: Foo, tracked val bar: Bar)
val qux = Qux(IntSet, foo, bar)

object Test:
  summon[qux.set.Set =:= qux.foo.set.Set]
  summon[qux.set.Set =:= qux.bar.set.Set]

If so, then I’m not sure we understand correctly what the snowballing concerns were.

Or alternatively, to avoid the redundant set parameter:

class Quy(tracked val foo: Foo, tracked val bar: Bar):
  val set: foo.set.type = foo.set
val quy = Quy(foo, bar)

object Test:
  summon[quy.set.Set =:= quy.foo.set.Set]
  summon[quy.set.Set =:= quy.bar.set.Set]
1 Like

This has a problem that you will not get those type equalities inside Qux.

1 Like

To be honest, some of the example code is extremely confusing for me.

If you have something like:

class A { type M }

val a1: A = new A { type M = Int }
val a2: A = new A { type M = Int }

def f1: a1.M = 123.asInstanceOf[a1.M]

val a3: A = a1

def f2: a3.M = f1.asInstanceOf[a3.M]

@main def t =
  println(f2)
  println(a1 == a3)

First of all, why is the compiler not able to “see” that a1.M is Int? How would I write this without a cast?

Secondly, saying that

A key point of path-dependent types is precisely that two abstract type members prefixed by different instances are different.

doesn’t seem correct in the context of the above example. The instances are identical as proven by the second println. They just appear under different names (are aliased).

Since when can’t I expect that substituting a pure val isn’t transparent to the program?

Path dependent types are really one of the most confusing parts of the language. But maybe it’s just me not grokking them… :sob:

First of all, why is the compiler not able to “see” that a1.M is Int ?

Once a1 has been successfully type-checked, the right-hand side of its definition becomes irrelevant as far as type checking (the remainder of the program) is concerned. There is no difference between typing the example with a1 and:

val b1: A = ???
def g1: b1.M = 123 // error

How would I write this without a cast?

It is enough to remember the rhs of the type definition, either by explicitly making it part of the value’s type:

val c1: A { type M = Int } = new A { type M = Int }
def h1: c1.M = 123 // ok

or implicitly, by using an object:

object c2 extends A { type M = Int }
def h2: c2.M = 123 // ok

Since when can’t I expect that substituting a pure val isn’t transparent to the program?

Consider the following example:

val x1 = 1 + 2 // ok

val x2 =
  val x21 = 1
  x21 + 2 // also ok, but only because the inferred type of x21 is "precise" enough

val x3 =
  val x31: Any = 1
  x31 + 2 // error, even though it would type-check after substituting x31

The fact that the original example involves path dependent types is not central to the issue at hand. In either case, we get an error because we have abstracted – i.e. widened – the type of a value: in the second example from Int to Any, and in the first from A { type M = Int } to A.

As previously mentioned, this “abstraction” is precisely the intended meaning of opaque types. Where c2 “remembers” the rhs of M, we can make it abstract, as we had for c1, via the opaque modifier:

object c3 extends A { opaque type M = Int }
def h3: c3.M = 123 // error again
3 Likes