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!
Comments
Be the first to comment on this lesson!