Writing Reusable Code with Functions

Introduction

Functions are the building blocks of any Scala program. They allow you to break down complex problems into smaller, manageable pieces, promote code reuse, and make your programs more modular and testable.

In this lesson, you'll learn how to define functions in Scala, work with parameters and return types, and leverage Scala's powerful type inference to write clean, expressive code. We'll also explore how functions in Scala are first-class citizens, setting the foundation for functional programming concepts.

Basic Function Syntax

Simple Function Definition

The basic syntax for defining a function in Scala uses the def keyword:

def functionName(parameter: Type): ReturnType = {
  // function body
  result
}

Let's start with a simple example:

def greet(name: String): String = {
  s"Hello, $name!"
}

// Calling the function
val message = greet("Alice")
println(message)  // "Hello, Alice!"

Single Expression Functions

For simple functions that contain only one expression, you can omit the curly braces:

def add(a: Int, b: Int): Int = a + b

def square(x: Int): Int = x * x

def isEven(n: Int): Boolean = n % 2 == 0

// Usage
println(add(5, 3))     // 8
println(square(4))     // 16
println(isEven(7))     // false

Type Inference

Scala's type inference can often determine return types automatically:

// Explicit return type (good practice for public methods)
def multiply(a: Int, b: Int): Int = a * b

// Inferred return type (compiler figures out it's Int)
def multiplyInferred(a: Int, b: Int) = a * b

// For simple functions, type inference works well
def max(a: Int, b: Int) = if (a > b) a else b

// But sometimes explicit types improve readability
def calculateArea(radius: Double): Double = math.Pi * radius * radius

Best Practice: Use explicit return types for:

  • Public methods in APIs
  • Complex functions where the return type isn't obvious
  • Recursive functions (required)

Functions with Multiple Parameters

Multiple Parameter Lists

Scala allows functions to have multiple parameter lists:

def multiply(x: Int)(y: Int): Int = x * y

// Usage
val result = multiply(5)(3)  // 15

// This enables currying (partial application)
val multiplyBy5 = multiply(5) _
val result2 = multiplyBy5(3)  // 15

Default Parameters

You can provide default values for parameters:

def greetUser(name: String, greeting: String = "Hello", punctuation: String = "!") = {
  s"$greeting, $name$punctuation"
}

// Usage with defaults
println(greetUser("Bob"))                    // "Hello, Bob!"
println(greetUser("Alice", "Hi"))            // "Hi, Alice!"
println(greetUser("Charlie", "Hey", "?"))    // "Hey, Charlie?"

Named Parameters

You can specify parameters by name, which allows you to change their order:

def createUser(name: String, age: Int, email: String, isActive: Boolean = true) = {
  s"User($name, $age, $email, $isActive)"
}

// Positional arguments
val user1 = createUser("Alice", 30, "alice@example.com")

// Named arguments (order doesn't matter)
val user2 = createUser(
  email = "bob@example.com",
  name = "Bob", 
  age = 25
)

// Mix of positional and named
val user3 = createUser("Charlie", age = 35, email = "charlie@example.com")

Return Types and Unit

Functions that Return Values

def calculateCircleArea(radius: Double): Double = {
  math.Pi * radius * radius
}

def getFullName(firstName: String, lastName: String): String = {
  s"$firstName $lastName"
}

def factorial(n: Int): Long = {
  if (n <= 1) 1L
  else n * factorial(n - 1)
}

Functions that Perform Actions (Unit)

Functions that don't return meaningful values have return type Unit:

def printWelcome(name: String): Unit = {
  println(s"Welcome, $name!")
  println("Please enjoy your stay.")
}

// Unit can be omitted (inferred)
def logMessage(message: String) = {
  val timestamp = java.time.LocalDateTime.now()
  println(s"[$timestamp] $message")
}

// Functions that only perform side effects
def saveToFile(filename: String, content: String): Unit = {
  import java.io.PrintWriter
  val writer = new PrintWriter(filename)
  try {
    writer.write(content)
  } finally {
    writer.close()
  }
}

Function Bodies and Blocks

Single Expression vs Block Body

// Single expression (no braces needed)
def double(x: Int) = x * 2

// Block body for multiple statements
def processNumber(x: Int): String = {
  val doubled = x * 2
  val squared = x * x
  val isEven = x % 2 == 0

  s"Number: $x, Doubled: $doubled, Squared: $squared, Even: $isEven"
}

// Block can contain complex logic
def analyzeGrade(score: Int): String = {
  val normalizedScore = math.max(0, math.min(100, score))  // Clamp to 0-100

  val letterGrade = if (normalizedScore >= 90) "A"
                   else if (normalizedScore >= 80) "B"
                   else if (normalizedScore >= 70) "C"
                   else if (normalizedScore >= 60) "D"
                   else "F"

  val status = if (normalizedScore >= 60) "Pass" else "Fail"

  s"Score: $normalizedScore, Grade: $letterGrade, Status: $status"
}

Local Functions

You can define functions inside other functions (local functions):

def calculateTax(income: Double, state: String): Double = {
  // Local function for federal tax
  def federalTax(amount: Double): Double = {
    if (amount <= 50000) amount * 0.1
    else if (amount <= 100000) 5000 + (amount - 50000) * 0.2
    else 15000 + (amount - 100000) * 0.3
  }

  // Local function for state tax
  def stateTax(amount: Double, state: String): Double = state.toLowerCase match {
    case "ca" => amount * 0.08
    case "ny" => amount * 0.07
    case "tx" => 0.0  // No state tax
    case _ => amount * 0.05  // Default rate
  }

  federalTax(income) + stateTax(income, state)
}

val totalTax = calculateTax(75000, "CA")
println(f"Total tax: $$${totalTax}%.2f")

Variable Arguments (Varargs)

Functions can accept a variable number of arguments:

def sum(numbers: Int*): Int = {
  numbers.sum
}

// Usage
println(sum(1, 2, 3))           // 6
println(sum(1, 2, 3, 4, 5))     // 15
println(sum())                  // 0

// More complex example
def createMessage(prefix: String, words: String*): String = {
  val content = words.mkString(" ")
  s"$prefix: $content"
}

println(createMessage("Info", "System", "is", "running"))
// "Info: System is running"

// You can pass a collection as varargs using :_*
val wordList = List("Hello", "world")
println(createMessage("Greeting", wordList: _*))
// "Greeting: Hello world"

Recursive Functions

Recursive functions must have explicit return types:

def factorial(n: Int): Long = {
  if (n <= 1) 1L
  else n * factorial(n - 1)
}

def fibonacci(n: Int): Long = {
  if (n <= 1) n.toLong
  else fibonacci(n - 1) + fibonacci(n - 2)
}

// Tail recursion (more efficient)
def factorialTailRec(n: Int): Long = {
  @annotation.tailrec
  def helper(remaining: Int, accumulator: Long): Long = {
    if (remaining <= 1) accumulator
    else helper(remaining - 1, remaining * accumulator)
  }

  helper(n, 1L)
}

// Testing
println(factorial(5))           // 120
println(fibonacci(10))          // 55
println(factorialTailRec(5))    // 120

Function Overloading

You can define multiple functions with the same name but different parameters:

def format(value: Int): String = value.toString

def format(value: Double): String = f"$value%.2f"

def format(value: String): String = s"'$value'"

def format(value: Boolean): String = if (value) "YES" else "NO"

// Usage
println(format(42))        // "42"
println(format(3.14159))   // "3.14"
println(format("hello"))   // "'hello'"
println(format(true))      // "YES"

// Overloading with different parameter counts
def max(a: Int, b: Int): Int = if (a > b) a else b

def max(a: Int, b: Int, c: Int): Int = max(max(a, b), c)

def max(numbers: Int*): Int = numbers.max

Practical Examples

Example 1: String Utilities

object StringUtils {
  def capitalize(text: String): String = {
    if (text.isEmpty) text
    else text.head.toUpper + text.tail.toLowerCase
  }

  def truncate(text: String, maxLength: Int, suffix: String = "..."): String = {
    if (text.length <= maxLength) text
    else text.take(maxLength - suffix.length) + suffix
  }

  def countWords(text: String): Int = {
    if (text.trim.isEmpty) 0
    else text.trim.split("\\s+").length
  }

  def isPalindrome(text: String): Boolean = {
    val cleaned = text.toLowerCase.replaceAll("[^a-z0-9]", "")
    cleaned == cleaned.reverse
  }

  def generateSlug(title: String): String = {
    title.toLowerCase
         .replaceAll("[^a-z0-9\\s]", "")
         .trim
         .replaceAll("\\s+", "-")
  }
}

// Usage
import StringUtils._

println(capitalize("hello world"))                    // "Hello world"
println(truncate("This is a long sentence", 10))     // "This is..."
println(countWords("Hello world from Scala"))        // 4
println(isPalindrome("A man a plan a canal Panama")) // true
println(generateSlug("My First Scala Program!"))     // "my-first-scala-program"

Example 2: Mathematical Functions

object MathUtils {
  def isPrime(n: Int): Boolean = {
    if (n <= 1) false
    else if (n <= 3) true
    else if (n % 2 == 0 || n % 3 == 0) false
    else {
      @annotation.tailrec
      def checkDivisors(i: Int): Boolean = {
        if (i * i > n) true
        else if (n % i == 0 || n % (i + 2) == 0) false
        else checkDivisors(i + 6)
      }
      checkDivisors(5)
    }
  }

  def gcd(a: Int, b: Int): Int = {
    @annotation.tailrec
    def gcdHelper(x: Int, y: Int): Int = {
      if (y == 0) x
      else gcdHelper(y, x % y)
    }
    gcdHelper(math.abs(a), math.abs(b))
  }

  def lcm(a: Int, b: Int): Int = {
    math.abs(a * b) / gcd(a, b)
  }

  def power(base: Double, exponent: Int): Double = {
    @annotation.tailrec
    def powerHelper(acc: Double, exp: Int): Double = {
      if (exp == 0) acc
      else if (exp % 2 == 0) powerHelper(acc * acc, exp / 2)
      else powerHelper(acc * base, exp - 1)
    }

    if (exponent >= 0) powerHelper(1.0, exponent)
    else 1.0 / powerHelper(1.0, -exponent)
  }
}

// Usage
import MathUtils._

println(isPrime(17))      // true
println(isPrime(15))      // false
println(gcd(48, 18))      // 6
println(lcm(12, 18))      // 36
println(power(2.0, 10))   // 1024.0

Example 3: Data Processing

case class Student(name: String, grades: List[Int])

object GradeProcessor {
  def calculateAverage(grades: List[Int]): Double = {
    if (grades.isEmpty) 0.0
    else grades.sum.toDouble / grades.length
  }

  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 processStudent(student: Student): String = {
    val avg = calculateAverage(student.grades)
    val letter = getLetterGrade(avg)
    val status = if (avg >= 60) "Passing" else "Failing"

    f"${student.name}: Average ${avg}%.1f ($letter) - $status"
  }

  def findTopStudent(students: List[Student]): Option[Student] = {
    if (students.isEmpty) None
    else {
      val bestStudent = students.maxBy(s => calculateAverage(s.grades))
      Some(bestStudent)
    }
  }

  def getClassStatistics(students: List[Student]): String = {
    if (students.isEmpty) "No students"
    else {
      val averages = students.map(s => calculateAverage(s.grades))
      val classAvg = averages.sum / averages.length
      val highest = averages.max
      val lowest = averages.min
      val passingCount = averages.count(_ >= 60)

      f"""Class Statistics:
         |Students: ${students.length}
         |Class Average: $classAvg%.1f
         |Highest Average: $highest%.1f
         |Lowest Average: $lowest%.1f
         |Passing Students: $passingCount/${students.length}""".stripMargin
    }
  }
}

// Usage
val students = List(
  Student("Alice", List(95, 87, 92, 88)),
  Student("Bob", List(78, 82, 75, 80)),
  Student("Charlie", List(55, 60, 58, 62)),
  Student("Diana", List(90, 94, 89, 97))
)

students.foreach(student => println(GradeProcessor.processStudent(student)))
println("\n" + GradeProcessor.getClassStatistics(students))

GradeProcessor.findTopStudent(students) match {
  case Some(student) => println(s"\nTop student: ${student.name}")
  case None => println("\nNo students found")
}

Common Patterns and Best Practices

1. Pure Functions

Prefer pure functions (no side effects, same input always produces same output):

// Pure function - good
def calculateTip(bill: Double, percentage: Double): Double = {
  bill * (percentage / 100)
}

// Impure function - has side effects
def calculateTipAndPrint(bill: Double, percentage: Double): Double = {
  val tip = bill * (percentage / 100)
  println(s"Tip: $$${tip}")  // Side effect: printing
  tip
}

// Better: Separate calculation from side effects
def formatTipMessage(bill: Double, tip: Double): String = {
  f"Bill: $$${bill}%.2f, Tip: $$${tip}%.2f, Total: $$${bill + tip}%.2f"
}

val bill = 50.0
val tip = calculateTip(bill, 18.0)
println(formatTipMessage(bill, tip))

2. Function Composition

Break complex problems into smaller functions:

def validateEmail(email: String): Boolean = {
  def hasAtSymbol(s: String): Boolean = s.contains("@")
  def hasValidFormat(s: String): Boolean = s.matches(""".+@.+\..+""")
  def isNotEmpty(s: String): Boolean = s.trim.nonEmpty

  isNotEmpty(email) && hasAtSymbol(email) && hasValidFormat(email)
}

def processUserInput(input: String): String = {
  def trimSpaces(s: String): String = s.trim
  def toLowerCase(s: String): String = s.toLowerCase
  def removeSpecialChars(s: String): String = s.replaceAll("[^a-z0-9]", "")

  removeSpecialChars(toLowerCase(trimSpaces(input)))
}

3. Error Handling

Use return types that express possible failure:

// Using Option for functions that might not return a value
def safeDivide(a: Double, b: Double): Option[Double] = {
  if (b == 0) None
  else Some(a / b)
}

// Using Either for functions that can fail with an error message
def parseAge(input: String): Either[String, Int] = {
  try {
    val age = input.toInt
    if (age < 0) Left("Age cannot be negative")
    else if (age > 150) Left("Age seems unrealistic")
    else Right(age)
  } catch {
    case _: NumberFormatException => Left(s"'$input' is not a valid number")
  }
}

// Usage
safeDivide(10, 2) match {
  case Some(result) => println(s"Result: $result")
  case None => println("Cannot divide by zero")
}

parseAge("25") match {
  case Right(age) => println(s"Valid age: $age")
  case Left(error) => println(s"Error: $error")
}

Summary

In this lesson, you've learned the fundamentals of writing functions in Scala:

Function Definition: Using def keyword with parameters and return types
Type Inference: Letting Scala figure out return types when appropriate
Parameter Features: Default values, named parameters, variable arguments
Function Bodies: Single expressions vs multi-statement blocks
Local Functions: Defining helper functions inside other functions
Recursion: Writing recursive functions with proper return types
Overloading: Multiple functions with the same name
Best Practices: Pure functions, composition, and error handling

Functions are essential building blocks that make your code modular, reusable, and testable. The concepts you've learned here will be crucial as we move into more advanced topics like higher-order functions and functional programming patterns.

What's Next

In the next lesson, we'll dive into "A Deeper Dive into Strings: Interpolation and Multiline." You'll master string manipulation in Scala, including advanced interpolation techniques, formatting options, and working with complex string patterns.

We'll explore how Scala's string features can make your code more readable and help you handle text processing tasks elegantly.

Ready to become a string manipulation expert? Let's continue!