Methods and Fields: Adding Behavior and State

Introduction

Methods and fields are the core components that bring classes to life. Fields store the state (data) of an object, while methods define the behavior (actions) that objects can perform. Understanding how to design and implement these effectively is crucial for building robust, maintainable object-oriented applications.

In this lesson, you'll learn advanced techniques for defining methods, working with different types of fields, understanding method overloading and overriding, and creating clean APIs for your classes.

Fields: Storing Object State

Field Types and Visibility

class Person(name: String, age: Int) {
  // Public mutable field
  var email: String = ""

  // Public immutable field
  val id: String = java.util.UUID.randomUUID().toString

  // Private mutable field
  private var _password: String = ""

  // Private immutable field
  private val createdAt: java.time.LocalDateTime = java.time.LocalDateTime.now()

  // Protected field (accessible in subclasses)
  protected var lastLoginAt: Option[java.time.LocalDateTime] = None

  // Computed field (no storage, computed each time)
  def isMinor: Boolean = age < 18

  // Lazy field (computed once, when first accessed)
  lazy val hashedPassword: String = computeHash(_password)

  private def computeHash(password: String): String = {
    // Simplified hash function for example
    s"hash_${password.hashCode}"
  }
}

val person = new Person("Alice", 25)
println(person.id)        // OK - public val
person.email = "alice@example.com"  // OK - public var
// person._password = "secret"  // Error - private field
println(person.isMinor)   // OK - computed field

Custom Getters and Setters

class Temperature {
  private var _celsius: Double = 0.0

  // Custom getter
  def celsius: Double = _celsius

  // Custom setter with validation
  def celsius_=(value: Double): Unit = {
    require(value >= -273.15, "Temperature cannot be below absolute zero")
    println(s"Setting temperature to ${value}°C")
    _celsius = value
  }

  // Read-only computed properties
  def fahrenheit: Double = _celsius * 9.0 / 5.0 + 32.0
  def kelvin: Double = _celsius + 273.15

  // Write-only setter for fahrenheit
  def fahrenheit_=(value: Double): Unit = {
    celsius = (value - 32.0) * 5.0 / 9.0
  }

  // Property with side effects
  def kelvin_=(value: Double): Unit = {
    println(s"Converting ${value}K to Celsius")
    celsius = value - 273.15
  }
}

val temp = new Temperature()
temp.celsius = 25.0      // Calls custom setter
println(temp.fahrenheit) // Calls computed getter
temp.fahrenheit = 100.0  // Calls fahrenheit setter, which calls celsius setter

Field Initialization and Lazy Evaluation

class DataProcessor(val dataSource: String) {
  // Eager initialization (computed during object creation)
  val connectionId: String = {
    println("Establishing connection...")
    s"conn_${System.currentTimeMillis()}"
  }

  // Lazy initialization (computed when first accessed)
  lazy val expensiveData: List[String] = {
    println("Loading expensive data...")
    Thread.sleep(1000)  // Simulate expensive operation
    List("data1", "data2", "data3")
  }

  // Method that uses lazy field
  def processData(): Unit = {
    println(s"Processing ${expensiveData.length} items")
    expensiveData.foreach(println)
  }
}

val processor = new DataProcessor("database")
println("Object created")  // connectionId is already computed
println("About to process...")
processor.processData()    // expensiveData is computed here

Methods: Defining Object Behavior

Method Definitions and Parameters

class Calculator {
  // Simple method
  def add(a: Int, b: Int): Int = a + b

  // Method with default parameters
  def multiply(a: Int, b: Int = 1): Int = a * b

  // Method with named parameters
  def divide(dividend: Double, divisor: Double): Double = {
    require(divisor != 0, "Division by zero")
    dividend / divisor
  }

  // Variable arguments (varargs)
  def sum(numbers: Int*): Int = numbers.sum

  // Multiple parameter lists
  def power(base: Double)(exponent: Int): Double = {
    math.pow(base, exponent)
  }

  // Method with implicit parameter (covered in advanced lessons)
  def format(value: Double)(implicit precision: Int): String = {
    f"$value%.${precision}f"
  }
}

val calc = new Calculator()
println(calc.add(5, 3))                    // 8
println(calc.multiply(4))                  // 4 (uses default)
println(calc.divide(dividend = 10, divisor = 3))  // Named parameters
println(calc.sum(1, 2, 3, 4, 5))         // 15
println(calc.power(2)(3))                 // 8.0

implicit val precision: Int = 2
println(calc.format(3.14159))             // "3.14"

Method Overloading

class Printer {
  // Overloaded methods with different parameter types
  def print(value: String): Unit = println(s"String: $value")
  def print(value: Int): Unit = println(s"Integer: $value")
  def print(value: Double): Unit = println(s"Double: $value")
  def print(value: Boolean): Unit = println(s"Boolean: $value")

  // Overloaded methods with different number of parameters
  def print(name: String, value: Any): Unit = println(s"$name: $value")
  def print(prefix: String, name: String, value: Any): Unit = 
    println(s"$prefix - $name: $value")

  // Overloaded methods with different parameter order
  def format(value: Double, precision: Int): String = f"$value%.${precision}f"
  def format(precision: Int, value: Double): String = f"$value%.${precision}f"
}

val printer = new Printer()
printer.print("Hello")           // String version
printer.print(42)               // Int version
printer.print(3.14)             // Double version
printer.print("Score", 95)      // Two parameter version

Methods with Side Effects vs Pure Methods

class Counter {
  private var _count: Int = 0

  // Impure method (has side effects)
  def increment(): Unit = {
    _count += 1
    println(s"Count incremented to ${_count}")
  }

  // Pure method (no side effects)
  def count: Int = _count

  // Pure method returning new state
  def incrementedBy(amount: Int): Int = _count + amount

  // Method that returns this for chaining
  def reset(): Counter = {
    _count = 0
    this
  }

  // Method chaining
  def add(amount: Int): Counter = {
    _count += amount
    this
  }
}

val counter = new Counter()
counter.increment()              // Side effect: prints and modifies state
println(counter.count)           // Pure: just returns current state
println(counter.incrementedBy(5)) // Pure: doesn't modify state

// Method chaining
counter.reset().add(10).add(5)
println(counter.count)           // 15

Advanced Method Patterns

Factory Methods

class User private(val username: String, val email: String, val hashedPassword: String) {
  override def toString: String = s"User($username, $email)"
}

object User {
  // Factory method with validation
  def create(username: String, email: String, password: String): Either[String, User] = {
    if (username.length < 3) {
      Left("Username must be at least 3 characters")
    } else if (!email.contains("@")) {
      Left("Invalid email format")
    } else if (password.length < 8) {
      Left("Password must be at least 8 characters")
    } else {
      val hashedPassword = hashPassword(password)
      Right(new User(username, email, hashedPassword))
    }
  }

  // Alternative factory method from data
  def fromCsv(csvLine: String): Option[User] = {
    csvLine.split(",") match {
      case Array(username, email, hashedPass) if username.nonEmpty && email.nonEmpty =>
        Some(new User(username.trim, email.trim, hashedPass.trim))
      case _ => None
    }
  }

  private def hashPassword(password: String): String = {
    s"hashed_${password.hashCode}"
  }
}

// Usage
User.create("alice", "alice@example.com", "secretpassword") match {
  case Right(user) => println(s"Created: $user")
  case Left(error) => println(s"Error: $error")
}

Template Method Pattern

abstract class DataProcessor {
  // Template method - defines the algorithm structure
  final def processData(input: String): String = {
    val validated = validateInput(input)
    val transformed = transformData(validated)
    val processed = processCore(transformed)
    val formatted = formatOutput(processed)
    logResult(formatted)
    formatted
  }

  // Abstract methods to be implemented by subclasses
  protected def processCore(data: String): String

  // Methods with default implementation (can be overridden)
  protected def validateInput(input: String): String = {
    require(input.nonEmpty, "Input cannot be empty")
    input.trim
  }

  protected def transformData(data: String): String = data.toLowerCase

  protected def formatOutput(data: String): String = s"Result: $data"

  protected def logResult(result: String): Unit = {
    println(s"Processing completed: ${result.take(50)}...")
  }
}

class UpperCaseProcessor extends DataProcessor {
  protected def processCore(data: String): String = data.toUpperCase
}

class ReverseProcessor extends DataProcessor {
  protected def processCore(data: String): String = data.reverse

  // Override default formatting
  protected override def formatOutput(data: String): String = s"Reversed: $data"
}

val upperProcessor = new UpperCaseProcessor()
val reverseProcessor = new ReverseProcessor()

println(upperProcessor.processData("  Hello World  "))
println(reverseProcessor.processData("  Hello World  "))

Fluent Interface (Method Chaining)

class QueryBuilder {
  private var table: String = ""
  private var columns: List[String] = List("*")
  private var whereConditions: List[String] = List()
  private var orderBy: List[String] = List()
  private var limitCount: Option[Int] = None

  def from(tableName: String): QueryBuilder = {
    table = tableName
    this
  }

  def select(cols: String*): QueryBuilder = {
    columns = cols.toList
    this
  }

  def where(condition: String): QueryBuilder = {
    whereConditions = whereConditions :+ condition
    this
  }

  def orderBy(column: String, direction: String = "ASC"): QueryBuilder = {
    this.orderBy = this.orderBy :+ s"$column $direction"
    this
  }

  def limit(count: Int): QueryBuilder = {
    limitCount = Some(count)
    this
  }

  def build(): String = {
    require(table.nonEmpty, "Table name is required")

    val selectClause = s"SELECT ${columns.mkString(", ")}"
    val fromClause = s"FROM $table"

    val parts = List(selectClause, fromClause)

    val whereClause = if (whereConditions.nonEmpty) {
      Some(s"WHERE ${whereConditions.mkString(" AND ")}")
    } else None

    val orderClause = if (orderBy.nonEmpty) {
      Some(s"ORDER BY ${orderBy.mkString(", ")}")
    } else None

    val limitClause = limitCount.map(count => s"LIMIT $count")

    (parts ++ List(whereClause, orderClause, limitClause).flatten).mkString(" ")
  }
}

// Usage with method chaining
val query = new QueryBuilder()
  .select("name", "email", "age")
  .from("users")
  .where("age >= 18")
  .where("active = true")
  .orderBy("name")
  .limit(10)
  .build()

println(query)
// SELECT name, email, age FROM users WHERE age >= 18 AND active = true ORDER BY name LIMIT 10

Practical Examples

Example 1: Shopping Cart System

case class Product(id: String, name: String, price: Double, category: String)

case class CartItem(product: Product, quantity: Int) {
  require(quantity > 0, "Quantity must be positive")

  def total: Double = product.price * quantity
  def withQuantity(newQuantity: Int): CartItem = copy(quantity = newQuantity)
}

class ShoppingCart {
  private var items: Map[String, CartItem] = Map()
  private var _discountCode: Option[String] = None

  def addItem(product: Product, quantity: Int = 1): ShoppingCart = {
    val productId = product.id
    items.get(productId) match {
      case Some(existingItem) =>
        items = items + (productId -> existingItem.withQuantity(existingItem.quantity + quantity))
      case None =>
        items = items + (productId -> CartItem(product, quantity))
    }
    this
  }

  def removeItem(productId: String): ShoppingCart = {
    items = items - productId
    this
  }

  def updateQuantity(productId: String, quantity: Int): ShoppingCart = {
    if (quantity <= 0) {
      removeItem(productId)
    } else {
      items.get(productId) match {
        case Some(item) => items = items + (productId -> item.withQuantity(quantity))
        case None => // Product not in cart, do nothing
      }
    }
    this
  }

  def applyDiscountCode(code: String): Boolean = {
    // Simple validation - in real app, this would check against a database
    if (code == "SAVE10" || code == "WELCOME") {
      _discountCode = Some(code)
      true
    } else {
      false
    }
  }

  def removeDiscountCode(): ShoppingCart = {
    _discountCode = None
    this
  }

  // Computed properties
  def subtotal: Double = items.values.map(_.total).sum

  def discountAmount: Double = _discountCode match {
    case Some("SAVE10") => subtotal * 0.1
    case Some("WELCOME") => math.min(subtotal * 0.05, 50.0)  // 5% up to $50
    case _ => 0.0
  }

  def total: Double = subtotal - discountAmount

  def itemCount: Int = items.values.map(_.quantity).sum

  def isEmpty: Boolean = items.isEmpty

  def getItems: List[CartItem] = items.values.toList.sortBy(_.product.name)

  def summary(): String = {
    if (isEmpty) {
      "Cart is empty"
    } else {
      val itemsList = getItems.map { item =>
        f"  ${item.product.name}%-20s x${item.quantity}%2d = $$${item.total}%6.2f"
      }.mkString("\n")

      val discountLine = if (discountAmount > 0) {
        f"\nDiscount (${_discountCode.get}): -$$${discountAmount}%.2f"
      } else ""

      s"""Shopping Cart Summary:
         |$itemsList
         |${"-" * 40}
         |Subtotal: $$${subtotal}%.2f$discountLine
         |Total: $$${total}%.2f
         |Items: $itemCount""".stripMargin
    }
  }

  def clear(): ShoppingCart = {
    items = Map()
    _discountCode = None
    this
  }
}

// Usage
val laptop = Product("P001", "Gaming Laptop", 1299.99, "Electronics")
val mouse = Product("P002", "Wireless Mouse", 49.99, "Electronics")
val keyboard = Product("P003", "Mechanical Keyboard", 129.99, "Electronics")

val cart = new ShoppingCart()
  .addItem(laptop)
  .addItem(mouse, 2)
  .addItem(keyboard)

println(cart.summary())

cart.applyDiscountCode("SAVE10")
println("\nAfter applying discount:")
println(cart.summary())

cart.updateQuantity("P002", 1)  // Reduce mouse quantity
println("\nAfter updating mouse quantity:")
println(cart.summary())

Example 2: File Manager with Metadata

import java.time.LocalDateTime
import java.nio.file.Path

case class FileMetadata(
  size: Long,
  createdAt: LocalDateTime,
  modifiedAt: LocalDateTime,
  isDirectory: Boolean,
  permissions: String
)

class ManagedFile(val path: Path, val metadata: FileMetadata) {

  // Computed properties
  def name: String = path.getFileName.toString
  def extension: String = {
    val name = this.name
    val lastDot = name.lastIndexOf('.')
    if (lastDot > 0) name.substring(lastDot + 1) else ""
  }

  def sizeInKB: Double = metadata.size / 1024.0
  def sizeInMB: Double = sizeInKB / 1024.0
  def sizeInGB: Double = sizeInMB / 1024.0

  def isTextFile: Boolean = {
    val textExtensions = Set("txt", "md", "csv", "json", "xml", "yml", "yaml")
    textExtensions.contains(extension.toLowerCase)
  }

  def isImageFile: Boolean = {
    val imageExtensions = Set("jpg", "jpeg", "png", "gif", "bmp", "svg")
    imageExtensions.contains(extension.toLowerCase)
  }

  def isCodeFile: Boolean = {
    val codeExtensions = Set("scala", "java", "py", "js", "ts", "cpp", "c", "h")
    codeExtensions.contains(extension.toLowerCase)
  }

  // Methods for file operations
  def formatSize(): String = {
    if (metadata.size < 1024) s"${metadata.size} B"
    else if (sizeInKB < 1024) f"${sizeInKB}%.1f KB"
    else if (sizeInMB < 1024) f"${sizeInMB}%.1f MB"
    else f"${sizeInGB}%.2f GB"
  }

  def getFileType(): String = {
    if (metadata.isDirectory) "Directory"
    else if (isTextFile) "Text File"
    else if (isImageFile) "Image File"
    else if (isCodeFile) "Code File"
    else extension.toUpperCase + " File"
  }

  def info(): String = {
    s"""File: $name
       |Path: $path
       |Type: ${getFileType()}
       |Size: ${formatSize()}
       |Created: ${metadata.createdAt}
       |Modified: ${metadata.modifiedAt}
       |Permissions: ${metadata.permissions}""".stripMargin
  }

  override def toString: String = s"$name (${formatSize()})"
}

class FileManager {
  private var files: List[ManagedFile] = List()

  def addFile(file: ManagedFile): FileManager = {
    files = files :+ file
    this
  }

  def removeFile(path: Path): FileManager = {
    files = files.filterNot(_.path == path)
    this
  }

  // Query methods
  def findByName(name: String): Option[ManagedFile] = {
    files.find(_.name.toLowerCase.contains(name.toLowerCase))
  }

  def findByExtension(extension: String): List[ManagedFile] = {
    files.filter(_.extension.toLowerCase == extension.toLowerCase)
  }

  def findLargerThan(sizeInMB: Double): List[ManagedFile] = {
    files.filter(_.sizeInMB > sizeInMB)
  }

  def findCreatedAfter(date: LocalDateTime): List[ManagedFile] = {
    files.filter(_.metadata.createdAt.isAfter(date))
  }

  // Statistics methods
  def totalSize: Long = files.map(_.metadata.size).sum
  def totalSizeFormatted: String = {
    val totalMB = totalSize / (1024.0 * 1024.0)
    if (totalMB < 1024) f"${totalMB}%.1f MB"
    else f"${totalMB / 1024.0}%.2f GB"
  }

  def fileTypeStats: Map[String, Int] = {
    files.groupBy(_.getFileType()).view.mapValues(_.length).toMap
  }

  def largestFiles(count: Int = 10): List[ManagedFile] = {
    files.sortBy(-_.metadata.size).take(count)
  }

  def generateReport(): String = {
    val fileCount = files.length
    val dirCount = files.count(_.metadata.isDirectory)
    val regularFileCount = fileCount - dirCount

    val typeStats = fileTypeStats.toSeq.sortBy(-_._2).map { case (fileType, count) =>
      f"  $fileType%-15s: $count%4d files"
    }.mkString("\n")

    val largest = largestFiles(5)
    val largestList = largest.map(f => f"  ${f.name}%-30s: ${f.formatSize()}").mkString("\n")

    s"""File Manager Report
       |==================
       |Total Files: $fileCount ($regularFileCount files, $dirCount directories)
       |Total Size: $totalSizeFormatted
       |
       |File Types:
       |$typeStats
       |
       |Largest Files:
       |$largestList
       |""".stripMargin
  }

  def listFiles(): Unit = {
    files.sortBy(_.name).foreach { file =>
      val typeIcon = if (file.metadata.isDirectory) "📁" else "📄"
      println(f"$typeIcon ${file.name}%-30s ${file.formatSize()}%10s  ${file.getFileType()}")
    }
  }
}

// Usage example (with mock data)
val manager = new FileManager()

// Create some sample files
val files = List(
  (java.nio.file.Paths.get("/documents/report.pdf"), FileMetadata(2500000, LocalDateTime.now().minusDays(5), LocalDateTime.now().minusDays(2), false, "rw-r--r--")),
  (java.nio.file.Paths.get("/images/photo.jpg"), FileMetadata(5500000, LocalDateTime.now().minusDays(10), LocalDateTime.now().minusDays(3), false, "rw-r--r--")),
  (java.nio.file.Paths.get("/code/app.scala"), FileMetadata(45000, LocalDateTime.now().minusDays(1), LocalDateTime.now(), false, "rw-r--r--")),
  (java.nio.file.Paths.get("/data/logs.txt"), FileMetadata(125000, LocalDateTime.now().minusDays(7), LocalDateTime.now().minusHours(2), false, "rw-r--r--")),
  (java.nio.file.Paths.get("/projects"), FileMetadata(0, LocalDateTime.now().minusDays(30), LocalDateTime.now().minusDays(1), true, "rwxr-xr-x"))
)

files.foreach { case (path, metadata) =>
  manager.addFile(new ManagedFile(path, metadata))
}

println(manager.generateReport())
println("\nFile Listing:")
manager.listFiles()

Summary

In this lesson, you've learned advanced techniques for working with methods and fields:

Field Types: Public, private, protected, lazy, and computed fields
Custom Accessors: Getters and setters with validation and side effects
Method Patterns: Overloading, chaining, factory methods, and templates
API Design: Creating fluent interfaces and clean class APIs
Practical Applications: Shopping cart, file management, and data processing
Best Practices: Pure vs impure methods, encapsulation, and validation

Understanding how to effectively use methods and fields is crucial for creating well-designed, maintainable classes that provide clear interfaces and robust functionality.

What's Next

In the next lesson, we'll explore "Singleton Objects: When You Only Need One." You'll learn about Scala's object keyword for creating singleton instances, perfect for utility methods, constants, and application entry points.

This will introduce you to one of Scala's unique features that bridges object-oriented and functional programming paradigms.

Ready to explore singleton objects? Let's continue!