Scala - Abstract Classes

Abstract classes serve as blueprints for other classes, defining common structure and behavior while leaving specific implementations to subclasses. You declare an abstract class using the `abstract`...

Key Insights

  • Abstract classes in Scala provide partial implementations and state management, making them ideal for defining base functionality that concrete subclasses will extend and specialize
  • Unlike traits, abstract classes support constructor parameters, allowing you to enforce initialization requirements and maintain immutable state across the inheritance hierarchy
  • Choose abstract classes over traits when you need a clear single-inheritance relationship, require constructor parameters, or want to maintain compatibility with Java frameworks

Understanding Abstract Classes in Scala

Abstract classes serve as blueprints for other classes, defining common structure and behavior while leaving specific implementations to subclasses. You declare an abstract class using the abstract keyword and can include both concrete and abstract members.

abstract class Vehicle(val manufacturer: String, val year: Int) {
  // Abstract method - no implementation
  def maxSpeed: Double
  
  // Abstract field
  val fuelType: String
  
  // Concrete method
  def age: Int = java.time.Year.now().getValue - year
  
  // Concrete method with implementation
  def displayInfo(): String = {
    s"$manufacturer vehicle from $year, fuel: $fuelType"
  }
}

class Car(manufacturer: String, year: Int, val model: String) 
  extends Vehicle(manufacturer, year) {
  
  override val fuelType: String = "Gasoline"
  override def maxSpeed: Double = 180.0
}

class ElectricCar(manufacturer: String, year: Int, val batteryCapacity: Int)
  extends Vehicle(manufacturer, year) {
  
  override val fuelType: String = "Electric"
  override def maxSpeed: Double = 200.0
  
  def range: Double = batteryCapacity * 0.5
}

The abstract class Vehicle defines the contract that all vehicles must follow while providing shared functionality like age and displayInfo.

Constructor Parameters and Initialization

One of the key advantages of abstract classes over traits is support for constructor parameters. This enables you to enforce initialization requirements and pass state up the inheritance chain.

abstract class DatabaseConnection(
  val host: String,
  val port: Int,
  val database: String
) {
  // Initialization block runs when subclass is instantiated
  require(port > 0 && port < 65536, "Invalid port number")
  require(database.nonEmpty, "Database name cannot be empty")
  
  protected val connectionString: String = 
    s"jdbc:postgresql://$host:$port/$database"
  
  def connect(): Unit
  def disconnect(): Unit
  def executeQuery(query: String): List[Map[String, Any]]
}

class PostgresConnection(
  host: String,
  port: Int,
  database: String,
  val username: String,
  val password: String
) extends DatabaseConnection(host, port, database) {
  
  private var connected: Boolean = false
  
  override def connect(): Unit = {
    println(s"Connecting to $connectionString as $username")
    connected = true
  }
  
  override def disconnect(): Unit = {
    if (connected) {
      println("Disconnecting from database")
      connected = false
    }
  }
  
  override def executeQuery(query: String): List[Map[String, Any]] = {
    require(connected, "Not connected to database")
    println(s"Executing: $query")
    List.empty // Simplified for example
  }
}

The abstract class enforces that all database connections must have host, port, and database values, with validation performed during construction.

Abstract Members and Template Method Pattern

Abstract classes excel at implementing the Template Method pattern, where you define the skeleton of an algorithm in a base class and let subclasses provide specific steps.

abstract class DataProcessor[T] {
  // Template method defines the algorithm structure
  final def process(data: List[T]): List[T] = {
    val validated = validate(data)
    val transformed = transform(validated)
    val filtered = filter(transformed)
    postProcess(filtered)
    filtered
  }
  
  // Abstract methods - must be implemented by subclasses
  protected def validate(data: List[T]): List[T]
  protected def transform(data: List[T]): List[T]
  protected def filter(data: List[T]): List[T]
  
  // Hook method - optional override
  protected def postProcess(data: List[T]): Unit = {}
}

class IntegerProcessor extends DataProcessor[Int] {
  override protected def validate(data: List[Int]): List[Int] = {
    data.filterNot(_ == 0)
  }
  
  override protected def transform(data: List[Int]): List[Int] = {
    data.map(_ * 2)
  }
  
  override protected def filter(data: List[Int]): List[Int] = {
    data.filter(_ > 10)
  }
  
  override protected def postProcess(data: List[Int]): Unit = {
    println(s"Processed ${data.size} integers")
  }
}

// Usage
val processor = new IntegerProcessor()
val result = processor.process(List(1, 5, 0, 8, 12, 3))
// Output: Processed 2 integers
// Result: List(16, 24)

The final modifier on process prevents subclasses from changing the algorithm structure, ensuring consistency across implementations.

Mixing Abstract Classes with Traits

Scala allows you to combine abstract classes with traits using the with keyword, giving you the benefits of both constructs.

trait Loggable {
  def log(message: String): Unit = {
    println(s"[${java.time.LocalTime.now()}] $message")
  }
}

trait Auditable {
  private var auditTrail: List[String] = List.empty
  
  def audit(action: String): Unit = {
    auditTrail = s"${java.time.Instant.now()}: $action" :: auditTrail
  }
  
  def getAuditTrail: List[String] = auditTrail.reverse
}

abstract class Repository[T](val name: String) {
  def save(entity: T): Unit
  def findById(id: Long): Option[T]
  def delete(id: Long): Boolean
}

class UserRepository 
  extends Repository[User]("UserRepository") 
  with Loggable 
  with Auditable {
  
  private var users: Map[Long, User] = Map.empty
  
  override def save(entity: User): Unit = {
    log(s"Saving user ${entity.id}")
    audit(s"SAVE user ${entity.id}")
    users = users + (entity.id -> entity)
  }
  
  override def findById(id: Long): Option[User] = {
    log(s"Finding user $id")
    audit(s"FIND user $id")
    users.get(id)
  }
  
  override def delete(id: Long): Boolean = {
    log(s"Deleting user $id")
    audit(s"DELETE user $id")
    users.get(id).exists { _ =>
      users = users - id
      true
    }
  }
}

case class User(id: Long, name: String, email: String)

This approach lets you use abstract classes for core functionality requiring constructor parameters while adding cross-cutting concerns through traits.

When to Choose Abstract Classes Over Traits

Abstract classes are preferable in several scenarios:

// Scenario 1: Need constructor parameters
abstract class ConfigurableService(config: Map[String, String]) {
  protected val timeout: Int = config.getOrElse("timeout", "30").toInt
  protected val retries: Int = config.getOrElse("retries", "3").toInt
  
  def execute(): Unit
}

// Scenario 2: Java interoperability
abstract class JavaCompatibleHandler {
  // Java frameworks can extend this cleanly
  def handle(request: String): String
}

// Scenario 3: Clear single inheritance hierarchy
abstract class Shape(val color: String) {
  def area: Double
  def perimeter: Double
}

class Circle(color: String, val radius: Double) extends Shape(color) {
  override def area: Double = math.Pi * radius * radius
  override def perimeter: Double = 2 * math.Pi * radius
}

class Rectangle(color: String, val width: Double, val height: Double) 
  extends Shape(color) {
  override def area: Double = width * height
  override def perimeter: Double = 2 * (width + height)
}

// Scenario 4: Maintaining state across hierarchy
abstract class StatefulWorker(val id: String) {
  private var taskCount: Int = 0
  
  protected def incrementTaskCount(): Unit = {
    taskCount += 1
  }
  
  def getTaskCount: Int = taskCount
  
  def doWork(): Unit
}

Use traits when you need multiple inheritance, don’t require constructor parameters, or want to define stackable modifications. Use abstract classes when you need constructor parameters, want to enforce single inheritance, or need better Java interoperability.

Sealed Abstract Classes for Exhaustive Pattern Matching

Combining sealed with abstract restricts subclasses to the same file, enabling exhaustive pattern matching checks at compile time.

sealed abstract class Result[+T]

case class Success[T](value: T) extends Result[T]
case class Failure(error: String, cause: Option[Throwable] = None) 
  extends Result[Nothing]
case object Pending extends Result[Nothing]

object ResultHandler {
  def handle[T](result: Result[T]): String = result match {
    case Success(value) => s"Got value: $value"
    case Failure(error, cause) => 
      s"Error: $error${cause.map(c => s" (${c.getMessage})").getOrElse("")}"
    case Pending => "Still processing..."
    // Compiler ensures all cases are covered
  }
  
  def getOrElse[T](result: Result[T], default: T): T = result match {
    case Success(value) => value
    case _ => default
  }
}

Sealed abstract classes form the foundation of algebraic data types in Scala, providing type-safe, exhaustive pattern matching for domain modeling.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.