Self-types vs inheritance vs composition

I know the topic has been discussed to death and there are a zillion blogs out there that explain it (and I’ve read half of them). But I’m still unclear on three aspects of self-types that I’d like to discuss.

1

Inheriting from traits breaks encapsulation. The typical example goes like this:

trait S1 { self: S2 =>
  def m1 = "m1 using " + m2
  // leak not available here
}
trait S2 { self: S3 =>
  def m2 = "m2 using " + m3
}
trait S3 {
  def m3 = "m3"
  def leak = "leak"
}

Indeed, the leak method would be available in S1 if I were to use inheritance instead (S1 extends S2 and S2 extends S3). What I don’t understand is why it is not available here. Is there any way for S1 to be used in a context where leak is not available? Is it hidden on purpose? (“This is not a limitation, it is a feature.”)

2

One argument I’ve seen in favor of self-types is that they make it possible to choose specific module implementations at assembly time. The argument goes like this: If S1 extends S2, one is committed to this particular S2 at the time S1 is defined. On the other hand, when using S1 { self: S2 =>, one can pick any refinement of S2 when things are put together.

I don’t understand this argument. It seems to me that I’m free to rely on a specialization S2a of S2 whether I use self-types or inheritance:

trait S1 { self: S2 =>
  def m1 = "m1 using " + m2
}
trait S2 {
  def m2 = "m2"
}
trait S2a extends S2 {
  override def m2 = "m2a"
}

val s = new S1 with S2a

trait T1 extends T2 {
  def m1 = "m1 using " + m2
}
trait T2 {
  def m2 = "m2"
}
trait T2a extends T2 {
  override def m2 = "m2a"
}

val t = new T1 with T2a

Doesn’t the T approach let me use an extension T2a of T2 in the same way the S approach lets me use S2a instead of S2?

3

To switch from inheritance to composition without using self-types, one could pass entire modules as objects to class constructors, something like:

class S1(s2: S2) {
  def m1 = "m1 using " + s2.m2
}
class S2(s3: S3) {
  def m2 = "m2 using " + s3.m3
}
class S3 {
  def m3 = "m3"
}

val s = new S1(new S2(new S3))

The two drawbacks I see are:

  • It’s a little harder to deal with cycles in the dependencies.
  • There are annoying s2. and s3. indirections everywhere.

Is that it or is there a more fundamental limitation to this approach?

Apologies for the long post, but this looked like the right place to have a good discussion on this.

MC

1 Like

Still no takers on this question? There has to be someone with the relevant knowledge around here…

I’m no Scala expert, but I figured I’d take a whack at this since you haven’t gotten any replies…

Is there any way for S1 to be used in a context where leak is not available?

Yes. Though you could construct it with val s = new S1 with S2 with S3 which would automatically leak the interface of S3, if you give s an explicit type of S1 then leak won’t be available as a method of the instance. Similarly, if you pass s to things expecting an S1 then those callees won’t have access to leak.

Is it hidden on purpose? (“This is not a limitation, it is a feature.”)

This is my impression; it’s both a limitation and a feature. If you want to mix interfaces, you use inheritance, so this is a different feature with different behavior (more similar to constructor arguments, as you mention later).

Doesn’t the T approach let me use an extension T2a of T2 in the same way the S approach lets me use S2a instead of S2?

The way you have things written, if you pass s to something expecting an S1 then the S2 interface (as well as S2a) won’t be surfaced to the callee. The same is not true of t, if the callee expects a T1 then that instance will necessarily be a T2 subclass.

drawbacks […] There are annoying s2. and s3. indirections everywhere.

The way I’ve seen the Cake pattern used, the indirection is still present. Just FYI, I’ve seen this with self-types so wouldn’t consider self-types to necessarily have this advantage.

Is that it or is there a more fundamental limitation to this approach?

Imagine needing something more complex, like

val s4 = new S4 val s = new S1(new S2(new S3(s4), s4))
Maybe this is what you meant by “cycles” (but this is a DI-graph though without cycles). I really like that with self-types, I don’t have to do this kind of management manually.

  1. I think you have a point there. I don’t know what the reasoning is behind that limitation.
    Your code actually doesn’t compile with the current version of Dotty (next generation Scala). Dotty forces you to write:
trait S1 { self: S2 with S3 =>
  def m1 = "m1 using " + m2
  // leak IS available here
}
trait S2 { self: S3 =>
  def m2 = "m2 using " + m3
}
trait S3 {
  def m3 = "m3"
  def leak = "leak"
}

That way leak is always available, but you still have to make the dependency on S3 explicit.

  1. The difference is mainly in the public interface of S1 and T1.
scala> val s: S1 = new S1 with S2a
s: S1 = $anon$1@43300e64

scala> val t: T1 = new T1 with T2a
t: T1 = $anon$1@354a1239

scala> t.m1
res1: String = m1 using m2a

scala> t.m2
res2: String = m2a

scala> s.m1
res3: String = m1 using m2a

scala> s.m2
<console>:13: error: value m2 is not a member of S1
       s.m2
         ^
  1. That’s just the age old inheritance (IS-A) vs composition (HAS-A) discussion. I think a lot has been written on that already.