Classes and Objects: The Blueprint of Your Application

Introduction

Object-Oriented Programming (OOP) is one of Scala's two main programming paradigms, alongside functional programming. Classes serve as blueprints for creating objects that encapsulate data and behavior together, making your code more organized, reusable, and easier to reason about.

In this lesson, you'll learn how to define classes in Scala, create instances (objects), work with constructors, and understand the fundamental concepts that make OOP in Scala both powerful and elegant.

Basic Class Definition

Simple Class

A class in Scala is defined using the class keyword:

class Person {
  // Class body - initially empty
}

// Creating instances
val person1 = new Person()
val person2 = new Person()

println(person1 == person2)  // false - different instances

Class with Fields

Add fields to store data:

class Person {
  var name: String = ""
  var age: Int = 0
  var email: String = ""
}

// Usage
val person = new Person()
person.name = "Alice"
person.age = 30
person.email = "alice@example.com"

println(s"${person.name} is ${person.age} years old")

Class with Methods

Add behavior through methods:

class Person {
  var name: String = ""
  var age: Int = 0

  def greet(): String = {
    s"Hello, I'm $name and I'm $age years old"
  }

  def haveBirthday(): Unit = {
    age += 1
    println(s"Happy birthday! $name is now $age")
  }

  def isAdult(): Boolean = age >= 18
}

// Usage
val person = new Person()
person.name = "Bob"
person.age = 17

println(person.greet())
println(s"Is adult: ${person.isAdult()}")
person.haveBirthday()
println(s"Is adult now: ${person.isAdult()}")

Constructors

Primary Constructor

The primary constructor is part of the class definition:

class Person(var name: String, var age: Int) {
  // Constructor body (executed when instance is created)
  println(s"Creating person: $name, age $age")

  // Validation in constructor
  require(age >= 0, "Age cannot be negative")
  require(name.nonEmpty, "Name cannot be empty")

  def greet(): String = s"Hello, I'm $name"

  def isAdult(): Boolean = age >= 18
}

// Usage
val person = new Person("Alice", 25)  // Prints: Creating person: Alice, age 25
println(person.greet())
println(person.name)  // Accessible because it's var

Constructor Parameters

Different parameter types have different accessibility:

class Person(
  var name: String,        // Public field (getter and setter)
  val birthYear: Int,      // Public read-only field (getter only)
  age: Int,                // Private parameter (not accessible outside)
  private val id: String   // Private field
) {

  // Computed property
  def currentAge: Int = java.time.Year.now().getValue - birthYear

  // Method using private parameter
  def canVote(): Boolean = currentAge >= 18

  // Using constructor parameter in method
  def ageCategory(): String = {
    if (age < 13) "child"
    else if (age < 20) "teenager"
    else if (age < 65) "adult"
    else "senior"
  }
}

val person = new Person("Alice", 1995, 28, "ID123")
println(person.name)         // OK - var parameter
println(person.birthYear)    // OK - val parameter
// println(person.age)       // Error - not accessible
// println(person.id)        // Error - private
println(person.currentAge)   // OK - computed property

Auxiliary Constructors

Classes can have multiple constructors:

class Person(var name: String, var age: Int, var email: String) {

  // Auxiliary constructor with name and age only
  def this(name: String, age: Int) = {
    this(name, age, "")  // Must call primary constructor
  }

  // Auxiliary constructor with name only
  def this(name: String) = {
    this(name, 0, "")
  }

  // Default constructor
  def this() = {
    this("Unknown", 0, "")
  }

  def info(): String = {
    val emailPart = if (email.nonEmpty) s", email: $email" else ""
    s"$name, age: $age$emailPart"
  }
}

// Usage with different constructors
val person1 = new Person("Alice", 25, "alice@example.com")
val person2 = new Person("Bob", 30)
val person3 = new Person("Charlie")
val person4 = new Person()

println(person1.info())  // Alice, age: 25, email: alice@example.com
println(person2.info())  // Bob, age: 30
println(person3.info())  // Charlie, age: 0
println(person4.info())  // Unknown, age: 0

Constructor with Default Parameters

A more elegant approach than auxiliary constructors:

class Person(
  var name: String,
  var age: Int = 0,
  var email: String = "",
  var active: Boolean = true
) {

  require(name.nonEmpty, "Name is required")
  require(age >= 0, "Age cannot be negative")

  def info(): String = {
    val status = if (active) "active" else "inactive"
    s"$name ($age years old) - $status"
  }

  def updateEmail(newEmail: String): Unit = {
    require(newEmail.contains("@"), "Invalid email format")
    email = newEmail
  }
}

// Usage with named parameters
val person1 = new Person("Alice")
val person2 = new Person("Bob", age = 25)
val person3 = new Person(name = "Charlie", email = "charlie@example.com", age = 30)
val person4 = new Person("Diana", 28, "diana@test.com", false)

List(person1, person2, person3, person4).foreach(p => println(p.info()))

Advanced Class Features

Private Fields and Methods

class BankAccount(initialBalance: Double) {
  private var _balance: Double = initialBalance
  private val _accountNumber: String = generateAccountNumber()

  // Private method
  private def generateAccountNumber(): String = {
    "ACC" + scala.util.Random.nextInt(1000000).toString.padTo(6, '0')
  }

  // Public getter for balance
  def balance: Double = _balance

  // Public getter for account number
  def accountNumber: String = _accountNumber

  def deposit(amount: Double): Unit = {
    require(amount > 0, "Deposit amount must be positive")
    _balance += amount
  }

  def withdraw(amount: Double): Boolean = {
    if (amount > 0 && amount <= _balance) {
      _balance -= amount
      true
    } else {
      false
    }
  }

  def transfer(amount: Double, toAccount: BankAccount): Boolean = {
    if (withdraw(amount)) {
      toAccount.deposit(amount)
      true
    } else {
      false
    }
  }
}

val account1 = new BankAccount(1000.0)
val account2 = new BankAccount(500.0)

println(s"Account ${account1.accountNumber}: ${account1.balance}")
account1.deposit(200.0)
println(s"After deposit: ${account1.balance}")

account1.transfer(300.0, account2)
println(s"Account 1 after transfer: ${account1.balance}")
println(s"Account 2 after transfer: ${account2.balance}")

Properties with Custom Getters and Setters

class Temperature {
  private var _celsius: Double = 0.0

  // Custom getter and setter for Celsius
  def celsius: Double = _celsius
  def celsius_=(value: Double): Unit = {
    require(value >= -273.15, "Temperature cannot be below absolute zero")
    _celsius = value
  }

  // Computed properties
  def fahrenheit: Double = _celsius * 9.0 / 5.0 + 32.0
  def fahrenheit_=(value: Double): Unit = {
    celsius = (value - 32.0) * 5.0 / 9.0
  }

  def kelvin: Double = _celsius + 273.15
  def kelvin_=(value: Double): Unit = {
    celsius = value - 273.15
  }

  override def toString: String = f"${_celsius}%.1f°C"
}

val temp = new Temperature()
temp.celsius = 25.0
println(s"Temperature: $temp")
println(f"Fahrenheit: ${temp.fahrenheit}%.1f°F")
println(f"Kelvin: ${temp.kelvin}%.1f K")

temp.fahrenheit = 100.0
println(s"After setting to 100°F: $temp")

temp.kelvin = 300.0
println(s"After setting to 300K: $temp")

Class with Companion Object

class Person(val name: String, val age: Int) {
  def greet(): String = s"Hello, I'm $name"

  def isAdult(): Boolean = age >= Person.ADULT_AGE

  override def toString: String = s"Person($name, $age)"
}

// Companion object (covered in detail in later lessons)
object Person {
  val ADULT_AGE = 18

  // Factory method
  def apply(name: String, age: Int): Person = new Person(name, age)

  // Utility methods
  def fromString(str: String): Option[Person] = {
    str.split(",") match {
      case Array(name, ageStr) =>
        try {
          Some(Person(name.trim, ageStr.trim.toInt))
        } catch {
          case _: NumberFormatException => None
        }
      case _ => None
    }
  }
}

// Usage
val person1 = new Person("Alice", 25)
val person2 = Person("Bob", 30)  // Using apply method

println(person1.greet())
println(s"Is ${person1.name} an adult? ${person1.isAdult()}")

Person.fromString("Charlie, 35") match {
  case Some(person) => println(s"Parsed: $person")
  case None => println("Failed to parse")
}

Practical Examples

Example 1: Book Management System

class Book(
  val title: String,
  val author: String,
  val isbn: String,
  val pages: Int,
  var available: Boolean = true
) {

  require(title.nonEmpty, "Title cannot be empty")
  require(author.nonEmpty, "Author cannot be empty")
  require(isbn.matches("\\d{10}|\\d{13}"), "Invalid ISBN format")
  require(pages > 0, "Pages must be positive")

  private var _borrowedBy: Option[String] = None
  private var _borrowDate: Option[java.time.LocalDate] = None

  def borrowedBy: Option[String] = _borrowedBy
  def borrowDate: Option[java.time.LocalDate] = _borrowDate

  def borrow(borrowerName: String): Boolean = {
    if (available) {
      _borrowedBy = Some(borrowerName)
      _borrowDate = Some(java.time.LocalDate.now())
      available = false
      true
    } else {
      false
    }
  }

  def returnBook(): Unit = {
    _borrowedBy = None
    _borrowDate = None
    available = true
  }

  def isOverdue(daysLimit: Int = 14): Boolean = {
    _borrowDate match {
      case Some(date) => 
        java.time.LocalDate.now().isAfter(date.plusDays(daysLimit))
      case None => false
    }
  }

  def info(): String = {
    val status = if (available) {
      "Available"
    } else {
      val borrower = _borrowedBy.getOrElse("Unknown")
      val date = _borrowDate.map(_.toString).getOrElse("Unknown")
      val overdue = if (isOverdue()) " (OVERDUE)" else ""
      s"Borrowed by $borrower on $date$overdue"
    }

    s"""
       |Title: $title
       |Author: $author
       |ISBN: $isbn
       |Pages: $pages
       |Status: $status
       |""".stripMargin
  }

  override def toString: String = s"$title by $author"
}

// Usage
val book1 = new Book("The Scala Programming Language", "Martin Odersky", "9780981531649", 883)
val book2 = new Book("Programming in Scala", "Dean Wampler", "9780596155957", 848)

println(book1.info())

book1.borrow("Alice Smith")
println(s"${book1.title} borrowed successfully: ${book1.borrowedBy}")
println(book1.info())

// Simulate checking after some time
println(s"Is overdue: ${book1.isOverdue(7)}")  // Check with 7-day limit

book1.returnBook()
println(s"Book returned. Available: ${book1.available}")

Example 2: Student Grade Manager

import scala.collection.mutable

class Student(
  val studentId: String,
  var firstName: String,
  var lastName: String,
  var email: String
) {

  private val grades = mutable.Map[String, List[Double]]()

  def fullName: String = s"$firstName $lastName"

  def addGrade(subject: String, grade: Double): Unit = {
    require(grade >= 0 && grade <= 100, "Grade must be between 0 and 100")

    val currentGrades = grades.getOrElse(subject, List.empty)
    grades(subject) = currentGrades :+ grade
  }

  def getGrades(subject: String): List[Double] = {
    grades.getOrElse(subject, List.empty)
  }

  def getAverage(subject: String): Option[Double] = {
    val subjectGrades = getGrades(subject)
    if (subjectGrades.nonEmpty) {
      Some(subjectGrades.sum / subjectGrades.length)
    } else {
      None
    }
  }

  def getOverallAverage(): Option[Double] = {
    val allGrades = grades.values.flatten.toList
    if (allGrades.nonEmpty) {
      Some(allGrades.sum / allGrades.length)
    } else {
      None
    }
  }

  def getSubjects(): List[String] = grades.keys.toList.sorted

  def getLetterGrade(average: Double): String = {
    if (average >= 90) "A"
    else if (average >= 80) "B"
    else if (average >= 70) "C"
    else if (average >= 60) "D"
    else "F"
  }

  def transcript(): String = {
    val header = s"TRANSCRIPT FOR: $fullName (ID: $studentId)\n${"=" * 50}"

    val subjectLines = getSubjects().map { subject =>
      val grades = getGrades(subject)
      val average = getAverage(subject).get
      val letter = getLetterGrade(average)
      f"$subject%-15s: ${grades.mkString(", ")}%20s | Avg: $average%5.1f ($letter)"
    }

    val overallAvg = getOverallAverage()
    val overallLine = overallAvg match {
      case Some(avg) => f"\nOVERALL AVERAGE: $avg%.1f (${getLetterGrade(avg)})"
      case None => "\nNo grades recorded"
    }

    (header :: subjectLines).mkString("\n") + overallLine
  }

  override def toString: String = s"Student($studentId: $fullName)"
}

// Usage
val student = new Student("S001", "Alice", "Johnson", "alice.johnson@school.edu")

// Add grades for different subjects
student.addGrade("Math", 85.0)
student.addGrade("Math", 92.0)
student.addGrade("Math", 78.0)

student.addGrade("Science", 88.0)
student.addGrade("Science", 91.0)

student.addGrade("English", 95.0)
student.addGrade("English", 89.0)
student.addGrade("English", 93.0)

println(student.transcript())

// Check specific subject average
student.getAverage("Math") match {
  case Some(avg) => println(f"\nMath average: $avg%.1f")
  case None => println("\nNo Math grades found")
}

Example 3: Simple Game Character

class GameCharacter(
  var name: String,
  var level: Int = 1,
  private var _health: Int = 100,
  private var _mana: Int = 50
) {

  require(name.nonEmpty, "Character must have a name")
  require(level > 0, "Level must be positive")

  private val maxHealth = 100 + (level - 1) * 20
  private val maxMana = 50 + (level - 1) * 10

  // Ensure health and mana don't exceed maximums
  _health = math.min(_health, maxHealth)
  _mana = math.min(_mana, maxMana)

  def health: Int = _health
  def mana: Int = _mana
  def maxHealthPoints: Int = maxHealth
  def maxManaPoints: Int = maxMana

  def isAlive: Boolean = _health > 0
  def isDead: Boolean = !isAlive

  def takeDamage(damage: Int): Unit = {
    require(damage >= 0, "Damage cannot be negative")
    _health = math.max(0, _health - damage)

    if (isDead) {
      println(s"$name has been defeated!")
    } else {
      println(s"$name takes $damage damage. Health: $_health/$maxHealth")
    }
  }

  def heal(amount: Int): Unit = {
    require(amount >= 0, "Heal amount cannot be negative")
    if (isAlive) {
      val oldHealth = _health
      _health = math.min(maxHealth, _health + amount)
      val actualHeal = _health - oldHealth
      println(s"$name heals for $actualHeal points. Health: $_health/$maxHealth")
    }
  }

  def useMana(cost: Int): Boolean = {
    if (_mana >= cost) {
      _mana -= cost
      true
    } else {
      false
    }
  }

  def restoreMana(amount: Int): Unit = {
    _mana = math.min(maxMana, _mana + amount)
  }

  def castSpell(spellName: String, manaCost: Int, damage: Int, target: GameCharacter): Unit = {
    if (isDead) {
      println(s"$name is dead and cannot cast spells!")
      return
    }

    if (useMana(manaCost)) {
      println(s"$name casts $spellName! (Mana: $_mana/$maxMana)")
      target.takeDamage(damage)
    } else {
      println(s"$name doesn't have enough mana to cast $spellName! (Need: $manaCost, Have: $_mana)")
    }
  }

  def levelUp(): Unit = {
    level += 1
    val oldMaxHealth = maxHealth - 20
    val oldMaxMana = maxMana - 10

    // Increase current health and mana proportionally
    _health = math.min(maxHealth, _health + 20)
    _mana = math.min(maxMana, _mana + 10)

    println(s"$name levels up to level $level!")
    println(s"Max Health: $oldMaxHealth → $maxHealth")
    println(s"Max Mana: $oldMaxMana → $maxMana")
  }

  def status(): String = {
    val healthBar = "█" * (_health * 10 / maxHealth) + "░" * (10 - _health * 10 / maxHealth)
    val manaBar = "█" * (_mana * 10 / maxMana) + "░" * (10 - _mana * 10 / maxMana)

    s"""
       |$name (Level $level)
       |Health: [$healthBar] $_health/$maxHealth
       |Mana:   [$manaBar] $_mana/$maxMana
       |Status: ${if (isAlive) "Alive" else "Dead"}
       |""".stripMargin
  }

  override def toString: String = s"$name (Lv.$level, HP:$_health/$maxHealth, MP:$_mana/$maxMana)"
}

// Usage - Battle simulation
val hero = new GameCharacter("Aragorn", 3)
val enemy = new GameCharacter("Orc Warrior", 2)

println("=== BATTLE START ===")
println(hero.status())
println(enemy.status())

// Battle rounds
hero.castSpell("Fireball", 15, 25, enemy)
enemy.castSpell("Club Smash", 5, 20, hero)

hero.castSpell("Lightning Bolt", 20, 30, enemy)
hero.heal(15)

enemy.castSpell("Rage", 10, 15, hero)

println("\n=== BATTLE END ===")
println(hero.status())
println(enemy.status())

// Level up the hero
hero.levelUp()
println(hero.status())

Common Patterns and Best Practices

1. Validation in Constructors

class Email(val address: String) {
  require(address.nonEmpty, "Email address cannot be empty")
  require(address.contains("@"), "Email must contain @")
  require(address.count(_ == '@') == 1, "Email must contain exactly one @")
  require(!address.startsWith("@"), "Email cannot start with @")
  require(!address.endsWith("@"), "Email cannot end with @")

  lazy val (localPart, domain) = {
    val parts = address.split("@")
    (parts(0), parts(1))
  }

  override def toString: String = address
}

2. Immutable Objects

class Point(val x: Double, val y: Double) {
  def move(dx: Double, dy: Double): Point = new Point(x + dx, y + dy)

  def distanceTo(other: Point): Double = {
    val dx = x - other.x
    val dy = y - other.y
    math.sqrt(dx * dx + dy * dy)
  }

  override def toString: String = s"Point($x, $y)"

  override def equals(obj: Any): Boolean = obj match {
    case other: Point => x == other.x && y == other.y
    case _ => false
  }

  override def hashCode(): Int = (x, y).hashCode()
}

3. Builder Pattern

class HttpRequest private(
  val method: String,
  val url: String,
  val headers: Map[String, String],
  val body: Option[String]
) {
  override def toString: String = s"$method $url"
}

class HttpRequestBuilder {
  private var method: String = "GET"
  private var url: String = ""
  private var headers: Map[String, String] = Map.empty
  private var body: Option[String] = None

  def setMethod(m: String): HttpRequestBuilder = {
    method = m
    this
  }

  def setUrl(u: String): HttpRequestBuilder = {
    url = u
    this
  }

  def addHeader(key: String, value: String): HttpRequestBuilder = {
    headers = headers + (key -> value)
    this
  }

  def setBody(b: String): HttpRequestBuilder = {
    body = Some(b)
    this
  }

  def build(): HttpRequest = {
    require(url.nonEmpty, "URL is required")
    new HttpRequest(method, url, headers, body)
  }
}

// Usage
val request = new HttpRequestBuilder()
  .setMethod("POST")
  .setUrl("https://api.example.com/users")
  .addHeader("Content-Type", "application/json")
  .addHeader("Authorization", "Bearer token123")
  .setBody("""{"name": "Alice", "email": "alice@example.com"}""")
  .build()

println(request)

Summary

In this lesson, you've learned the fundamentals of classes and objects in Scala:

✅ Class Definition: How to define classes with fields and methods
✅ Constructors: Primary and auxiliary constructors with parameters
✅ Access Modifiers: Controlling visibility with private, protected, and public
✅ Properties: Custom getters and setters for controlled access
✅ Encapsulation: Hiding internal state and exposing clean interfaces
✅ Validation: Ensuring object invariants through constructor requirements
✅ Real-world Examples: Building practical classes for common scenarios

Classes are the foundation of object-oriented design in Scala. They help you model real-world entities and create reusable, maintainable code structures.

What's Next

In the next lesson, we'll explore "Methods and Fields: Adding Behavior and State." You'll learn advanced techniques for defining methods, working with different types of fields, and understanding method overloading and override behavior.

We'll also dive deeper into how methods and fields work together to create clean, expressive APIs for your classes.

Ready to add more sophistication to your classes? Let's continue!