Skip to Content
Course content

26: Building Custom Collections

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Do I really have to implement every single method in the collection traits?

Short answer: No. If you tried to implement every method in Iterable or Seq manually, you'd be here all week. The magic of Scala's collection library is that it's designed around a very small "primitive" set of methods.

For most custom collections, the only thing you actually need to do is implement the iterator method. Once you provide a way to traverse your data, you automatically inherit map, filter, fold, flatMap, and dozens of other combinators for free. I always tell people to think of the iterator as the "engine" of your collection; as long as the engine works, the rest of the car just runs.

case class CircularBuffer[A](capacity: Int, private var buffer: Vector[A] = Vector.empty) 
  extends Iterable[A] {
  
  def add(elem: A): Unit = {
    if (buffer.length < capacity) {
      buffer = buffer :+ elem
    } else {
      buffer = buffer.tail :+ elem
    }
  }

  // This is the only method we MUST implement to get the Iterable powers
  override def iterator: Iterator[A] = buffer.iterator
}

In the example above, because CircularBuffer extends Iterable, I can now call buffer.map(_.toString) or buffer.filter(x => x > 10) without writing a single line of logic for those operations.

Why does my custom collection return a generic Iterable instead of my own type?

This is the part that usually frustrates developers the first time they build a collection. You call myBuffer.map(...) and instead of getting back a CircularBuffer, you get a generic Iterable. This happens because the default implementation of map doesn't know how to "re-wrap" the results into your specific custom class.

In modern Scala (2.13+ and Scala 3), the library has moved away from the complex CanBuildFrom pattern, but you still have to be explicit if you want to preserve your type. The simplest way to handle this is to override the transformation methods or provide a factory. But honestly? In most real-world production code, I usually just implement a helper method or wrap the result back into the custom collection manually.

case class CircularBuffer[A](capacity: Int, private var buffer: Vector[A] = Vector.empty) 
  extends Iterable[A] {
  
  override def iterator: Iterator[A] = buffer.iterator

  // We override map to ensure we return a CircularBuffer instead of a generic Iterable
  override def map[B](f: A => B): CircularBuffer[B] = {
    val newBuffer = CircularBuffer[B](capacity)
    this.foreach(item => newBuffer.add(f(item)))
    newBuffer
  }

  def add(elem: A): Unit = {
    if (buffer.length < capacity) buffer = buffer :+ elem
    else buffer = buffer.tail :+ elem
  }
}

It feels like a bit of boilerplate, but it's the only way to maintain the "fluent" API where you can chain operations and keep your specialized logic (like the fixed capacity) intact.

How do I make my collection truly immutable?

If you're building a collection for a multi-threaded environment, you can't just mark your internal variables as private and call it immutable. You need to change the way you update the data. Instead of modifying the internal state (like I did with var buffer above), every "mutation" method must return a new instance of your collection.

I recommend using a case class for this. Since case classes are immutable by default, you can use the copy method or simply instantiate a new version of your collection with the updated data. This is how Scala's List and Vector work under the hood—they use structural sharing so they aren't copying the entire dataset every time you add an element.

case class ImmutableCircularBuffer[A](capacity: Int, buffer: Vector[A] = Vector.empty) 
  extends Iterable[A] {
  
  override def iterator: Iterator[A] = buffer.iterator

  def add(elem: A): ImmutableCircularBuffer[A] = {
    val nextBuffer = if (buffer.length < capacity) {
      buffer :+ elem
    } else {
      buffer.tail :+ elem
    }
    // Return a brand new instance
    this.copy(buffer = nextBuffer)
  }
}

Now, when you call add, the original buffer remains unchanged. This is a huge win for debugging and concurrency, though it does come with a slight memory overhead because you're creating new objects.




📋 Practical Task

Exercise: Building a Time-Limited Event Log

Your task is to build a custom collection called EventLog. This collection should behave like a Seq but with a specific constraint: it should only keep the last N events added to it. If a new event is added and the log is full, the oldest event should be dropped.

  • Extend Iterable[T].
  • Implement a method log(event: T): Unit that adds an item to the collection while maintaining the size limit.
  • Override the map method so that calling log.map(...) returns a new EventLog of the same capacity, rather than a generic Iterable.
  • Ensure the iterator is implemented correctly so that standard Scala methods like sum or filter work out of the box.

Test your implementation: Create an EventLog with a capacity of 3, add 5 different strings to it, and verify that only the last 3 are present when you print the collection.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.