7  Families of Concepts: Inheritance with traits

As you build more complex software, you’ll start to notice patterns. Imagine you’re creating a system to manage company files. You might start by building a WordDocument class, a Spreadsheet class, and a PdfDocument class. Soon, you’ll realize they all need a print() method, they all have a fileSize, and they all need to be opened().

Are you going to copy and paste the fileSize logic and the print() method into all three classes? The moment you do, you create a maintenance nightmare. If you need to update the printing logic, you’ll have to find and change it in three different places. This violates a core principle we’ve learned: Don’t Repeat Yourself (DRY).

There has to be a better way—and there is. It’s called inheritance.

Inheritance is the mechanism for creating “IS-A” relationships between classes. A PdfDocument IS A type of Document. A Car IS A type of Vehicle. This allows us to define common behaviors and attributes in one central place, promoting code reuse and creating logical, understandable families of classes.

In modern Scala, the primary tool for this is the trait.

7.1 What is a trait? More Than Just a Contract

We’ve briefly described a trait as a contract. Let’s create a richer analogy.

  • Analogy: Roles and Abilities Think of a person. A person can have multiple roles or abilities. Someone can be an Employee, a Parent, and a Musician.
    • The Employee role comes with abilities like attendMeeting() and submitReport().
    • The Parent role comes with abilities like prepareLunch().
    • The Musician role comes with abilities like playInstrument().
    A trait in Scala is like one of these roles. It’s a bundle of behaviors and characteristics that a class can “mix in” to gain new abilities. Crucially, a class can mix in multiple traits, just as a person can have many roles.

A trait can provide two kinds of members to a class:

  1. Abstract Members (The “What”): These are contractual obligations. The trait defines what must be done, but not how. It’s a method or value that the class must implement itself. This is the contract part of the analogy.
  2. Concrete Members (The “How”): These are default, implemented methods and values. The trait provides common, shared behavior that a class gets for free, just by extending the trait. This is the code reuse part.

7.2 Hands-On: Modeling a Family of Documents

Let’s build a rich example that shows both of these powers in action. We will model a file system.

7.2.1 Step 1: The Core Blueprint — The Document trait

First, we’ll define the essential characteristics that all documents in our system must have.

// This trait defines the core concept of a "Document".
trait Document {
  // 1. An ABSTRACT value. Any class extending Document MUST define this.
  val filename: String

  // 2. An ABSTRACT method. The class must provide its own way to open.
  def open(): Unit

  // 3. A CONCRETE method. Any class extending Document gets this for FREE.
  // Notice how it can use the abstract 'filename' value, even though it's
  // not defined here. It trusts that the implementing class will provide it.
  def save(): Unit = {
    println(s"Saving document: $filename...")
    // In a real system, file-saving logic would go here.
  }
}

This trait is a beautiful mix. It forces implementing classes to define their own filename and open behavior, while giving them a shared, pre-built save method.

7.2.2 Step 2: Implementing the Concrete Classes

Now, let’s create two different types of documents that fulfill the Document contract.

// A PdfDocument IS A Document.
class PdfDocument(val filename: String) extends Document {

  // We MUST implement the abstract 'open' method.
  override def open(): Unit = {
    println(s"Opening PDF file '$filename' in a PDF reader.")
  }
}

// A WordDocument IS A Document.
class WordDocument(val filename: String) extends Document {
  
  // It has its OWN, different implementation of 'open'.
  override def open(): Unit = {
    println(s"Opening Word file '$filename' in Microsoft Word.")
  }
}

Let’s test them out:

val myResume = new PdfDocument("resume_final.pdf")
val myReport = new WordDocument("q3_sales_report.docx")

myResume.open()      // Calls the PDF-specific open method
myReport.open()      // Calls the Word-specific open method

myResume.save()      // Calls the SHARED save method from the Document trait
myReport.save()      // Also calls the SHARED save method

7.2.3 Step 3: Adding More Abilities with Mixins

What if only some documents are printable? We can define that ability in its own separate trait.

// This trait defines the ROLE or ABILITY of being printable.
trait Printable {
  // This is a concrete method. Any class that is Printable gets it.
  def print(): Unit = {
    println("Sending to the default printer...")
  }
}

Now, we can “mix in” this Printable ability to the classes that need it using the with keyword.

// A PDF is a Document AND it is Printable.
class PdfDocument(val filename: String) extends Document with Printable {
  override def open(): Unit = {
    println(s"Opening PDF file '$filename' in a PDF reader.")
  }
}

// A Word Document is also a Document AND Printable.
class WordDocument(val filename: String) extends Document with Printable {
  override def open(): Unit = {
    println(s"Opening Word file '$filename' in Microsoft Word.")
  }
}

// A Video File IS A Document, but it is NOT Printable.
class VideoFile(val filename: String) extends Document {
  override def open(): Unit = {
    println(s"Playing video file '$filename' in a media player.")
  }
}

Now, our PdfDocument and WordDocument have gained a new print() method, while VideoFile has not. This is the power of mixing in roles.

val finalContract = new PdfDocument("signed_contract.pdf")
val movieClip = new VideoFile("launch_video.mp4")

finalContract.print() // This works!
// movieClip.print()  // ERROR! This line would not compile. A VideoFile is not Printable.

7.3 Context and Best Practices

  • Tip 1: Use traits for Behaviors and Roles Think of traits as adjectives or abilities. Good names for traits are often nouns ending in “-able” or “-er” (e.g., Printable, Clickable, Serializable, Logger). You use them to add capabilities to a class. Classes, in contrast, represent concrete things or nouns (Customer, Invoice).

  • Tip 2: Why traits are often better than abstract class You might see another keyword in older Scala code or other languages: abstract class. It’s similar to a trait, but with one major limitation: a class can only extend one other class. However, a class can extend many traits (e.g., ... extends Document with Printable with Serializable). This makes traits far more flexible for composing behaviors, like snapping together building blocks of functionality.

  • Tip 3: Designing to an Interface When you use a trait, you are practicing a powerful design principle called “coding to an interface.” You are defining a general contract (Document) that other parts of your system can rely on, without needing to know the specific details of the concrete classes (PdfDocument, WordDocument). This “decoupling” makes your code much easier to change and extend later on.

We have now successfully created a “family” of related but distinct classes. They are all Documents and share a common contract, but they can also have their own unique abilities. This sets the stage perfectly for the next chapter, where we will learn how to leverage this family structure to write incredibly flexible and powerful code.