Pre-SIP: Companion Traits

When defining a trait, it should be possible to specify functions, which the companion object of an implementing class must define.

Syntax might be:

trait TMyTrait {
  // members which the extending class must define
  } withCompanion {
  // members which the companion object of the extending class must define
  }

Example:

trait TCollection[N, TSelf] {
    def elements: Iterable[N]
    def add(el: N): TSelf
  }

  /**
   * store elements in different collections, according to their categories
   * @tparam N type of elements to store
   * @tparam TColl type of underlying collections
   * @tparam TCont type of this container of collections
   */
  trait TContainer[N, TColl <: TCollection[N, TColl], TCont <: TContainer[N, TColl, TCont]] {
    def category(el: N): Int
    def collections: Map[Int, TColl]
    def add(el: N): TCont
  }

  final case class DoubleCollection(override val elements: Set[Double]) extends TCollection[Double, DoubleCollection] {
    override def add(el: Double): DoubleCollection = DoubleCollection(elements + el)
  }

  object DoubleCollection {
    val empty = DoubleCollection(Set.empty[Double])
  }

  final case class DoubleContainer(override val collections: Map[Int, DoubleCollection])
    extends TContainer[Double, DoubleCollection, DoubleContainer] {

    override def category(el: Double): Int = Math.log(el).floor.toInt

    override def add(el: Double): DoubleContainer = {
      val cat = category(el)
      val newCollection = collections.getOrElse(cat, DoubleCollection.empty).add(el)
      DoubleContainer(collections.updated(cat, newCollection))
    }
  }

Note that the function add can’t be defined in trait TContainer, because it uses DoubleCollection.empty. So when defining the same structure for e.g. Float, Decimal or Rational, we need to re-implement this function add, only because we can’t access the underlying function empty in the trait’s code.
With the proposed feature however, the function add could be defined on trait level:

class sip2 {

  trait TCollection[N, TSelf] {
    def elements: Iterable[N]
    def add(el: N): TSelf
  } withCompanion {
    def empty: TSelf
  }

  /**
   * store elements in different collections, according to their categories
   */
  trait TContainer[N, TColl <: TCollection[N, TColl], TCont <: TContainer[N, TColl, TCont]] {
    def category(el: N): Int
    def collections: Map[Int, TColl]

    override def add(el: N): TCont = {
      val cat = category(el)
      // in the following, `TColl.empty` translates to the companion object's function
      // `empty`, which is known to the compiler due to the `withCompanion`-block
      val newCollection = collections.getOrElse(cat, TColl.empty).add(el)
      DoubleContainer(collections.updated(cat, newCollection))
    }

  }

  final case class DoubleCollection(override val elements: Set[Double]) extends TCollection[Double, DoubleCollection] {
    override def add(el: Double): DoubleCollection = DoubleCollection(elements + el)
  }

  object DoubleCollection {
    // the following definition is enforced by the compiler
    val empty = DoubleCollection(Set.empty[Double])
  }

  final case class DoubleContainer(override val collections: Map[Int, DoubleCollection])
    extends TContainer[Double, DoubleCollection, DoubleContainer] {

    override def category(el: Double): Int = Math.log(el).floor.toInt

  }

The withCompanion block should also allow defining members on companion objects of extending classes:

  trait TBase {
    // ...
  } withCompanion {
    def toBeDefinedOnCompanionObject: Any
  }

  trait TMiddle extends TBase withCompanion {
    override def toBeDefinedOnCompanionObject: Any = new Object()
  }

  class MyClass extends TMiddle {
    // ...
  }

  object MyClass {
    // implementation of `toBeDefinedOnCompanionObject` is already defined by TMiddle
  }

Why?

1 Like

The standard library collections have companions that extend a factory trait.

Let me fire up IntelliJ to refresh my memory, or I guess I’ll let IntelliJ update first to latest and greatest.

sealed abstract class List[+A]
  extends AbstractSeq[A]
    with LinearSeq[A]
    with LinearSeqOps[A, List, List[A]]
    with StrictOptimizedLinearSeqOps[A, List, List[A]]
    with StrictOptimizedSeqOps[A, List, List[A]]
    with IterableFactoryDefaults[A, List]
    with DefaultSerializable {

  override def iterableFactory: SeqFactory[List] = List
}
object List extends StrictOptimizedSeqFactory[List]

Random sample usage

def appended[B >: A](elem: B): CC[B] = iterableFactory.from(new View.Appended(this, elem))

The iterableFactory member has an old alias called companion.

A common idiom to solve this problem is to use a typeclass to define what “Empty” means. This could then be supplied in the companion object, or elsewhere if neccesary.

trait Empty[A] {
  def empty: A
}

trait TContainer[N, TColl <: TCollection[N, TColl], TCont <: TContainer[N, TColl, TCont]] {
    def category(el: N): Int
    def collections: Map[Int, TColl]

    override def add(el: N)(implicit emptyTColl: Empty[TColl]: TCont = {
      val cat = category(el)
      val newCollection = collections.getOrElse(cat, emptyTColl.empty).add(el)
      DoubleContainer(collections.updated(cat, newCollection))
    }
}
1 Like

if this is a proposal for Scala 3 then this distinction is pretty much solved by type class pattern and extension methods.

trait TCollection[TSelf] {
  type Element
  // the members added to TSelf
  extension(self: TSelf) {
    def elements: Iterable[Element]
    def add(el: Element): TSelf
  }
  // the "Static" members
  def empty: TSelf
}
3 Likes

thanks, that works fine (when also providing a factory method for generating the TCollection, to get rid of the DoubleContainer(...).
Nevertheless, this solution

  • does not enforce the definition of the members required in the implicit arguments when implementing the classes
  • requires the implicits to be provided when using the add function

So yes your solution works fine, but I’d prefer the language to provide a solution which overcomes these issues.

This approach actually can be improved such that I wonder whether my SIP is already obsolete: defining a trait which the companion needs to extend and providing this companion as implicit argument:


  trait TCollection[N, TSelf] {
    def elements: Iterable[N]
    def add(el: N): TSelf
  }

  trait TCollectionCompanion[N, TColl, TCont] {
    def empty: TColl
    def wrap(underlying: Map[Int, TColl]): TCont
  }

  /**
   * store elements in different collections, according to their categories
   * @tparam N type of elements to store
   * @tparam TColl type of underlying collections
   * @tparam TCont type of this container of collections
   */
  trait TContainer[N, TColl <: TCollection[N, TColl], TCont <: TContainer[N, TColl, TCont]] {
    def category(el: N): Int
    def collections: Map[Int, TColl]
    def add(el: N)(implicit collectionCompanion: TCollectionCompanion[N, TColl, TCont]): TCont = {
      val cat = category(el)
      val newCollection = collections.getOrElse(cat, collectionCompanion.empty).add(el)
      collectionCompanion.wrap(collections.updated(cat, newCollection))
    }

  }

  final case class DoubleCollection(override val elements: Set[Double]) extends TCollection[Double, DoubleCollection] {
    override def add(el: Double): DoubleCollection = DoubleCollection(elements + el)
  }

  object DoubleCollection extends TCollectionCompanion[Double, DoubleCollection, DoubleContainer] {
    val empty = DoubleCollection(Set.empty[Double])

    override def wrap(underlying: Map[Int, DoubleCollection]): DoubleContainer = DoubleContainer(underlying)
  }

  final case class DoubleContainer(override val collections: Map[Int, DoubleCollection])
    extends TContainer[Double, DoubleCollection, DoubleContainer] {

    override def category(el: Double): Int = Math.log(el).floor.toInt

  }

  object Test {
    val doubleContainer = DoubleContainer(Map.empty)
    doubleContainer.add(123.4)(DoubleCollection)
  }

This way the compiler requires the DoubleCollection either implicitly or explicitly when calling function add. It also tells the developer that the required argument must inherit from TCollectionCompanion, which in turn tells what functions need to be implemented. Not perfect but fine. Thanks again for this hint!

sorry, I don’t understand this pattern:

  trait TCollection[TInstanceType] {
    type Element

    // the "Static" members
    def empty: TInstanceType

    extension (collection: TInstanceType) {
      // "non-static" members
      def elements: Set[Element]
      def add(el: Element): TInstanceType
    }
  }

  object DoubleCollection extends TCollection[Set[Double]] {

    override type Element = Double

    override val empty = Set.empty[Double]

  }

on the one hand, I don’t understand how to override the extension functions, on the other I still have no generic object providing def empty

like this

trait TCollection[TSelf] {
  type Element
  // the members added to TSelf
  extension(self: TSelf) {
    def elements: Iterable[Element]
    def add(el: Element): TSelf
  }
  // the "Static" members
  def empty: TSelf
}

given DoubleCollection: TCollection[Set[Double]] with {

  override type Element = Double

  override val empty = Set.empty[Double]

  extension(self: Set[Double]) {
    def elements: Iterable[Double] = self
    def add(el: Double): Set[Double] = self + el
  }
}

and usage like

scala> summon[TCollection[?] { type Element = Double }]
val res0: DoubleCollection.type = DoubleCollection$@56dde9f7

scala> val empty = summon[TCollection[?] { type Element = Double }].empty
val empty: Set[Double] = Set()

scala> def addElem[E, Col](col: Col, elem: E)(using c: TCollection[Col] { type Element = E }) = col.add(elem)
def addElem
  [E, Col](col: Col, elem: E)(using c: TCollection[Col]{type Element = E}): Col

scala> addElem(empty, 23.2)
val res1: Set[Double] = Set(23.2)
1 Like

This is the first time to look at Scala 3 and I definitely need some digging to understand what’s going on. But I do believe that my use case is indeed covered by the magic provided by summon. So thanks a lot for this short introduction, I’ll have a closer look starting with an introduction in the new features of Scala 3.