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.