Introduction to Functional Programming in Scala
Introduction
Functional Programming (FP) is one of Scala's most powerful paradigms, working seamlessly alongside object-oriented programming to create elegant, maintainable, and robust code. Unlike imperative programming that focuses on how to do things through step-by-step instructions, functional programming emphasizes what to compute through the composition of functions.
This lesson introduces you to the core concepts of functional programming in Scala, setting the foundation for advanced topics like higher-order functions, collections, and monads. You'll learn how to think functionally and why this approach leads to more predictable, testable, and scalable code.
Core Principles of Functional Programming
Immutability
Immutability means that once an object is created, it cannot be changed. Instead of modifying existing objects, you create new ones with the desired changes.
// Mutable approach (avoid in FP)
import scala.collection.mutable
val mutableList = mutable.ListBuffer(1, 2, 3)
mutableList += 4 // Modifies the existing list
mutableList(0) = 10 // Changes the first element
println(mutableList) // ListBuffer(10, 2, 3, 4)
// Immutable approach (FP style)
val immutableList = List(1, 2, 3)
val newList = immutableList :+ 4 // Creates a new list
val anotherList = 10 +: immutableList.tail // Creates another new list
println(immutableList) // List(1, 2, 3) - unchanged
println(newList) // List(1, 2, 3, 4)
println(anotherList) // List(10, 2, 3)
// Immutable data structures
case class Person(name: String, age: Int, email: String)
val alice = Person("Alice", 30, "alice@example.com")
val olderAlice = alice.copy(age = 31) // Creates new instance
val aliceWithNewEmail = alice.copy(email = "alice.smith@example.com")
println(alice) // Person(Alice,30,alice@example.com) - unchanged
println(olderAlice) // Person(Alice,31,alice@example.com)
println(aliceWithNewEmail) // Person(Alice,30,alice.smith@example.com)
// Immutable collections operations
val numbers = List(1, 2, 3, 4, 5)
val doubled = numbers.map(_ * 2) // Creates new list
val evens = numbers.filter(_ % 2 == 0) // Creates new list
val sum = numbers.reduce(_ + _) // Returns value, doesn't modify list
println(numbers) // List(1, 2, 3, 4, 5) - original unchanged
println(doubled) // List(2, 4, 6, 8, 10)
println(evens) // List(2, 4)
println(sum) // 15
Pure Functions
Pure functions are the cornerstone of functional programming. They have two key properties:
- Deterministic: Same input always produces the same output
- No side effects: Don't modify external state or perform I/O operations
// Pure functions - deterministic and no side effects
def add(x: Int, y: Int): Int = x + y
def multiply(x: Double, y: Double): Double = x * y
def isEven(n: Int): Boolean = n % 2 == 0
def square(x: Int): Int = x * x
// Testing pure functions - always predictable
println(add(5, 3)) // Always 8
println(multiply(2.5, 4.0)) // Always 10.0
println(isEven(42)) // Always true
println(square(7)) // Always 49
// More complex pure functions
def factorial(n: Int): Int = {
if (n <= 1) 1
else n * factorial(n - 1)
}
def fibonacci(n: Int): Int = {
if (n <= 1) n
else fibonacci(n - 1) + fibonacci(n - 2)
}
def isPrime(n: Int): Boolean = {
if (n <= 1) false
else if (n <= 3) true
else if (n % 2 == 0 || n % 3 == 0) false
else {
var i = 5
while (i * i <= n) {
if (n % i == 0 || n % (i + 2) == 0) return false
i += 6
}
true
}
}
// Pure function composition
def addOne(x: Int): Int = x + 1
def timesTen(x: Int): Int = x * 10
val composedFunction = (x: Int) => timesTen(addOne(x))
println(composedFunction(5)) // (5 + 1) * 10 = 60
// Testing pure functions is easy and reliable
val testCases = List(0, 1, 2, 3, 4, 5)
testCases.foreach { n =>
println(s"factorial($n) = ${factorial(n)}")
println(s"fibonacci($n) = ${fibonacci(n)}")
println(s"isPrime($n) = ${isPrime(n)}")
}
// Impure functions (avoid these patterns)
var globalCounter = 0
def impureIncrement(): Int = { // Impure: modifies global state
globalCounter += 1
globalCounter
}
def impurePrint(message: String): Unit = { // Impure: performs I/O
println(s"Log: $message")
}
def impureRandom(): Double = { // Impure: non-deterministic
scala.util.Random.nextDouble()
}
// Better alternatives using pure approaches
def pureIncrement(current: Int): Int = current + 1
def createLogMessage(message: String): String = s"Log: $message"
def generateRandomSeed(seed: Long): (Double, Long) = {
val random = new scala.util.Random(seed)
(random.nextDouble(), seed + 1)
}
Referential Transparency
An expression is referentially transparent if it can be replaced by its value without changing the program's behavior. This property is crucial for reasoning about functional code.
// Referentially transparent expressions
val x = 5
val y = 10
val sum = x + y // Can be replaced with 15
def pure(a: Int, b: Int): Int = a * 2 + b
val result1 = pure(3, 4) // Can be replaced with 10
val result2 = pure(3, 4) // Always the same as result1
// Demonstrating referential transparency
val list = List(1, 2, 3, 4, 5)
val doubled1 = list.map(x => x * 2)
val doubled2 = list.map(_ * 2)
val doubled3 = List(2, 4, 6, 8, 10) // Could replace any of the above
println(doubled1 == doubled2) // true
println(doubled1 == doubled3) // true
// Function composition with referential transparency
def addTwo(x: Int): Int = x + 2
def multiplyByThree(x: Int): Int = x * 3
val number = 5
val step1 = addTwo(number) // 7
val step2 = multiplyByThree(step1) // 21
// Can be simplified to:
val directResult = multiplyByThree(addTwo(5)) // 21
// Or using function composition
val composed = (x: Int) => multiplyByThree(addTwo(x))
val composedResult = composed(5) // 21
// All produce the same result due to referential transparency
println(step2 == directResult) // true
println(directResult == composedResult) // true
Higher-Order Functions Basics
Higher-order functions are functions that either take other functions as parameters or return functions as results. They're fundamental to functional programming.
Functions as Parameters
// Basic higher-order functions
def applyFunction(x: Int, f: Int => Int): Int = f(x)
def double(x: Int): Int = x * 2
def square(x: Int): Int = x * x
def increment(x: Int): Int = x + 1
val number = 5
println(applyFunction(number, double)) // 10
println(applyFunction(number, square)) // 25
println(applyFunction(number, increment)) // 6
// Using anonymous functions (lambdas)
println(applyFunction(number, x => x * 3)) // 15
println(applyFunction(number, x => x - 1)) // 4
println(applyFunction(number, _ + 10)) // 15
// Higher-order functions with multiple parameters
def combineNumbers(x: Int, y: Int, f: (Int, Int) => Int): Int = f(x, y)
val a = 8
val b = 3
println(combineNumbers(a, b, _ + _)) // 11 (addition)
println(combineNumbers(a, b, _ - _)) // 5 (subtraction)
println(combineNumbers(a, b, _ * _)) // 24 (multiplication)
println(combineNumbers(a, b, math.max)) // 8 (maximum)
println(combineNumbers(a, b, math.min)) // 3 (minimum)
// Custom operations
def modulo(x: Int, y: Int): Int = x % y
def power(x: Int, y: Int): Int = math.pow(x, y).toInt
println(combineNumbers(a, b, modulo)) // 2 (8 % 3)
println(combineNumbers(a, b, power)) // 512 (8^3)
// Working with collections using higher-order functions
val numbers = List(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
// map - transforms each element
val doubled = numbers.map(_ * 2)
val squared = numbers.map(x => x * x)
val stringified = numbers.map(_.toString)
println(doubled) // List(2, 4, 6, 8, 10, 12, 14, 16, 18, 20)
println(squared) // List(1, 4, 9, 16, 25, 36, 49, 64, 81, 100)
println(stringified) // List(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
// filter - selects elements based on predicate
val evens = numbers.filter(_ % 2 == 0)
val greaterThanFive = numbers.filter(_ > 5)
val primes = numbers.filter(isPrime)
println(evens) // List(2, 4, 6, 8, 10)
println(greaterThanFive) // List(6, 7, 8, 9, 10)
println(primes) // List(2, 3, 5, 7)
// reduce - combines elements using a binary operation
val sum = numbers.reduce(_ + _)
val product = numbers.reduce(_ * _)
val maximum = numbers.reduce(math.max)
println(sum) // 55
println(product) // 3628800
println(maximum) // 10
Functions as Return Values
// Functions that return functions
def multiplyBy(factor: Int): Int => Int = {
x => x * factor
}
val double = multiplyBy(2)
val triple = multiplyBy(3)
val halve = multiplyBy(0.5) // This won't compile due to type mismatch
// Correct version for doubles
def multiplyByDouble(factor: Double): Double => Double = {
x => x * factor
}
val halveDouble = multiplyByDouble(0.5)
println(double(10)) // 20
println(triple(7)) // 21
println(halveDouble(8.0)) // 4.0
// More complex function factories
def createValidator(minLength: Int, maxLength: Int): String => Boolean = {
text => text.length >= minLength && text.length <= maxLength
}
val passwordValidator = createValidator(8, 20)
val usernameValidator = createValidator(3, 15)
println(passwordValidator("secret")) // false (too short)
println(passwordValidator("secretpassword")) // true
println(usernameValidator("john")) // true
println(usernameValidator("verylongusername")) // false (too long)
// Function composition helpers
def compose[A, B, C](f: B => C, g: A => B): A => C = {
x => f(g(x))
}
val addOne = (x: Int) => x + 1
val multiplyByTwo = (x: Int) => x * 2
val addThenMultiply = compose(multiplyByTwo, addOne)
val multiplyThenAdd = compose(addOne, multiplyByTwo)
println(addThenMultiply(5)) // (5 + 1) * 2 = 12
println(multiplyThenAdd(5)) // (5 * 2) + 1 = 11
// Currying - converting multi-parameter functions to single-parameter functions
def add(x: Int, y: Int): Int = x + y
def curriedAdd(x: Int): Int => Int = y => x + y
val addFive = curriedAdd(5)
val addTen = curriedAdd(10)
println(addFive(3)) // 8
println(addTen(7)) // 17
// Scala's built-in currying support
def curriedMultiply(x: Int)(y: Int): Int = x * y
val timesFour = curriedMultiply(4) _ // Partial application
println(timesFour(6)) // 24
// Using curried functions with collections
val numbers = List(1, 2, 3, 4, 5)
val addedToAll = numbers.map(curriedAdd(10))
println(addedToAll) // List(11, 12, 13, 14, 15)
Recursion: The Functional Loop
In functional programming, recursion replaces traditional loops. Well-designed recursive functions are often more expressive and easier to reason about than imperative loops.
Basic Recursion Patterns
// Tail recursion - efficient recursive pattern
import scala.annotation.tailrec
@tailrec
def factorialTailRec(n: Int, acc: Int = 1): Int = {
if (n <= 1) acc
else factorialTailRec(n - 1, n * acc)
}
@tailrec
def fibonacciTailRec(n: Int, a: Int = 0, b: Int = 1): Int = {
if (n == 0) a
else if (n == 1) b
else fibonacciTailRec(n - 1, b, a + b)
}
// List processing with recursion
@tailrec
def sumList(list: List[Int], acc: Int = 0): Int = list match {
case Nil => acc
case head :: tail => sumList(tail, acc + head)
}
@tailrec
def findMax(list: List[Int], currentMax: Int = Int.MinValue): Int = list match {
case Nil => currentMax
case head :: tail => findMax(tail, math.max(head, currentMax))
}
def reverseList[A](list: List[A]): List[A] = {
@tailrec
def loop(remaining: List[A], acc: List[A]): List[A] = remaining match {
case Nil => acc
case head :: tail => loop(tail, head :: acc)
}
loop(list, Nil)
}
// Testing recursive functions
val numbers = List(5, 2, 8, 1, 9, 3)
println(s"Sum: ${sumList(numbers)}") // Sum: 28
println(s"Max: ${findMax(numbers)}") // Max: 9
println(s"Reversed: ${reverseList(numbers)}") // Reversed: List(3, 9, 1, 8, 2, 5)
println(s"Factorial of 5: ${factorialTailRec(5)}") // 120
println(s"10th Fibonacci: ${fibonacciTailRec(10)}") // 55
// Tree recursion examples
sealed trait Tree[+A]
case object Empty extends Tree[Nothing]
case class Node[A](value: A, left: Tree[A], right: Tree[A]) extends Tree[A]
def treeSize[A](tree: Tree[A]): Int = tree match {
case Empty => 0
case Node(_, left, right) => 1 + treeSize(left) + treeSize(right)
}
def treeHeight[A](tree: Tree[A]): Int = tree match {
case Empty => 0
case Node(_, left, right) => 1 + math.max(treeHeight(left), treeHeight(right))
}
def treeSum(tree: Tree[Int]): Int = tree match {
case Empty => 0
case Node(value, left, right) => value + treeSum(left) + treeSum(right)
}
def treeContains[A](tree: Tree[A], target: A): Boolean = tree match {
case Empty => false
case Node(value, left, right) =>
value == target || treeContains(left, target) || treeContains(right, target)
}
// Example tree: 5
// / \
// 3 8
// / \ / \
// 1 4 7 9
val sampleTree = Node(5,
Node(3, Node(1, Empty, Empty), Node(4, Empty, Empty)),
Node(8, Node(7, Empty, Empty), Node(9, Empty, Empty))
)
println(s"Tree size: ${treeSize(sampleTree)}") // 7
println(s"Tree height: ${treeHeight(sampleTree)}") // 3
println(s"Tree sum: ${treeSum(sampleTree)}") // 37
println(s"Contains 7: ${treeContains(sampleTree, 7)}") // true
println(s"Contains 6: ${treeContains(sampleTree, 6)}") // false
Function Composition and Pipelining
Function composition is the process of combining simple functions to build more complex ones. It's a fundamental technique in functional programming.
Basic Composition
// Function composition operators
val addOne = (x: Int) => x + 1
val multiplyByTwo = (x: Int) => x * 2
val square = (x: Int) => x * x
// Manual composition
val addThenMultiply = (x: Int) => multiplyByTwo(addOne(x))
val addThenSquare = (x: Int) => square(addOne(x))
println(addThenMultiply(5)) // (5 + 1) * 2 = 12
println(addThenSquare(3)) // (3 + 1)^2 = 16
// Using andThen for left-to-right composition
val pipeline1 = addOne andThen multiplyByTwo andThen square
println(pipeline1(2)) // ((2 + 1) * 2)^2 = 36
// Using compose for right-to-left composition
val pipeline2 = square compose multiplyByTwo compose addOne
println(pipeline2(2)) // Same result: 36
// Complex pipeline example
def isPositive(x: Int): Boolean = x > 0
def toString(x: Int): String = x.toString
def addPrefix(s: String): String = s"Number: $s"
val processNumber = addOne andThen multiplyByTwo andThen toString andThen addPrefix
println(processNumber(4)) // "Number: 10"
// Working with collections in pipelines
val words = List("hello", "world", "functional", "programming")
val pipeline = words
.filter(_.length > 5) // Keep words longer than 5 chars
.map(_.toUpperCase) // Convert to uppercase
.map(word => s"[$word]") // Add brackets
.sorted // Sort alphabetically
println(pipeline) // List([FUNCTIONAL], [PROGRAMMING], [WORLD])
// Function composition with error handling
def safeDivide(x: Double, y: Double): Option[Double] = {
if (y != 0) Some(x / y) else None
}
def addSafely(x: Option[Double], y: Double): Option[Double] = {
x.map(_ + y)
}
def multiplySafely(x: Option[Double], y: Double): Option[Double] = {
x.map(_ * y)
}
val safeCalculation = (x: Double, y: Double) =>
safeDivide(x, y)
.flatMap(result => addSafely(Some(result), 10))
.flatMap(result => multiplySafely(result, 2))
println(safeCalculation(20, 4)) // Some(30.0) - (20/4 + 10) * 2
println(safeCalculation(20, 0)) // None - division by zero
Practical Functional Programming Examples
Data Processing Pipeline
// Sample data
case class Product(id: Int, name: String, category: String, price: Double, rating: Double)
val products = List(
Product(1, "Laptop", "Electronics", 999.99, 4.5),
Product(2, "Coffee Mug", "Kitchen", 12.99, 4.2),
Product(3, "Book", "Education", 29.99, 4.8),
Product(4, "Headphones", "Electronics", 199.99, 4.3),
Product(5, "Desk Chair", "Furniture", 299.99, 4.1),
Product(6, "Notebook", "Education", 5.99, 4.0),
Product(7, "Monitor", "Electronics", 449.99, 4.6),
Product(8, "Pen Set", "Office", 24.99, 3.9)
)
// Functional data processing
def analyzeProducts(products: List[Product]): Map[String, Double] = {
products
.filter(_.rating >= 4.0) // High-rated products only
.groupBy(_.category) // Group by category
.view.mapValues { categoryProducts => // Calculate average price per category
val totalPrice = categoryProducts.map(_.price).sum
val count = categoryProducts.length
totalPrice / count
}.toMap
}
def findTopProducts(products: List[Product], n: Int): List[Product] = {
products
.filter(_.price > 50) // Exclude very cheap items
.sortBy(p => (-p.rating, p.price)) // Sort by rating desc, then price asc
.take(n) // Take top N
}
def categorySummary(products: List[Product]): List[(String, Int, Double, Double)] = {
products
.groupBy(_.category)
.toList
.map { case (category, categoryProducts) =>
val count = categoryProducts.length
val avgPrice = categoryProducts.map(_.price).sum / count
val avgRating = categoryProducts.map(_.rating).sum / count
(category, count, avgPrice, avgRating)
}
.sortBy(_._1) // Sort by category name
}
// Using the functional pipelines
val averagePrices = analyzeProducts(products)
println("Average prices by category (high-rated products):")
averagePrices.foreach { case (category, avgPrice) =>
println(f" $category: $$${avgPrice}%.2f")
}
val topProducts = findTopProducts(products, 3)
println("\nTop 3 products:")
topProducts.foreach { product =>
println(f" ${product.name} - ${product.rating} stars - $$${product.price}%.2f")
}
val summary = categorySummary(products)
println("\nCategory Summary:")
summary.foreach { case (category, count, avgPrice, avgRating) =>
println(f" $category: $count products, avg price: $$${avgPrice}%.2f, avg rating: ${avgRating}%.1f")
}
Functional Configuration
// Functional approach to configuration
case class DatabaseConfig(host: String, port: Int, database: String)
case class ServerConfig(host: String, port: Int, ssl: Boolean)
case class AppConfig(database: DatabaseConfig, server: ServerConfig, debug: Boolean)
// Pure functions for configuration validation and transformation
def validatePort(port: Int): Either[String, Int] = {
if (port > 0 && port <= 65535) Right(port)
else Left(s"Invalid port: $port. Must be between 1 and 65535")
}
def validateHost(host: String): Either[String, String] = {
if (host.nonEmpty && host.length <= 255) Right(host.toLowerCase)
else Left(s"Invalid host: $host")
}
def parseConfig(env: Map[String, String]): Either[String, AppConfig] = {
for {
dbHost <- env.get("DB_HOST").toRight("Missing DB_HOST").flatMap(validateHost)
dbPort <- env.get("DB_PORT").toRight("Missing DB_PORT").flatMap(port =>
scala.util.Try(port.toInt).toEither.left.map(_ => s"Invalid DB_PORT: $port").flatMap(validatePort)
)
dbName <- env.get("DB_NAME").toRight("Missing DB_NAME")
serverHost <- env.get("SERVER_HOST").toRight("Missing SERVER_HOST").flatMap(validateHost)
serverPort <- env.get("SERVER_PORT").toRight("Missing SERVER_PORT").flatMap(port =>
scala.util.Try(port.toInt).toEither.left.map(_ => s"Invalid SERVER_PORT: $port").flatMap(validatePort)
)
ssl = env.get("SSL_ENABLED").exists(_.toLowerCase == "true")
debug = env.get("DEBUG").exists(_.toLowerCase == "true")
} yield {
AppConfig(
DatabaseConfig(dbHost, dbPort, dbName),
ServerConfig(serverHost, serverPort, ssl),
debug
)
}
}
// Test configuration parsing
val validEnv = Map(
"DB_HOST" -> "localhost",
"DB_PORT" -> "5432",
"DB_NAME" -> "myapp",
"SERVER_HOST" -> "0.0.0.0",
"SERVER_PORT" -> "8080",
"SSL_ENABLED" -> "true",
"DEBUG" -> "false"
)
val invalidEnv = Map(
"DB_HOST" -> "",
"DB_PORT" -> "99999",
"DB_NAME" -> "myapp"
// Missing server config
)
parseConfig(validEnv) match {
case Right(config) => println(s"Valid config: $config")
case Left(error) => println(s"Configuration error: $error")
}
parseConfig(invalidEnv) match {
case Right(config) => println(s"Valid config: $config")
case Left(error) => println(s"Configuration error: $error")
}
Benefits of Functional Programming
Testability
// Pure functions are easy to test
def calculateTax(amount: Double, rate: Double): Double = amount * rate
def calculateTotal(subtotal: Double, taxRate: Double, discount: Double): Double = {
val tax = calculateTax(subtotal, taxRate)
subtotal + tax - discount
}
// Simple, predictable tests
val testCases = List(
(100.0, 0.08, 0.0, 108.0), // Basic case
(100.0, 0.0, 0.0, 100.0), // No tax
(100.0, 0.08, 10.0, 98.0), // With discount
(0.0, 0.08, 0.0, 0.0) // Zero amount
)
testCases.foreach { case (subtotal, taxRate, discount, expected) =>
val result = calculateTotal(subtotal, taxRate, discount)
val passed = math.abs(result - expected) < 0.001
println(s"Test ${if (passed) "PASSED" else "FAILED"}: $subtotal + tax($taxRate) - $discount = $result (expected $expected)")
}
Composability
// Small, composable functions
val numbers = List(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
val isEven = (n: Int) => n % 2 == 0
val square = (n: Int) => n * n
val greaterThan25 = (n: Int) => n > 25
// Compose operations declaratively
val result1 = numbers
.filter(isEven)
.map(square)
.filter(greaterThan25)
println(result1) // List(36, 64, 100)
// Easy to modify and extend
val isOdd = (n: Int) => n % 2 != 0
val cube = (n: Int) => n * n * n
val result2 = numbers
.filter(isOdd)
.map(cube)
.filter(greaterThan25)
println(result2) // List(27, 125, 343, 729)
Summary
In this lesson, you've learned the foundational concepts of functional programming in Scala:
✅ Immutability: Creating new objects instead of modifying existing ones
✅ Pure Functions: Deterministic functions without side effects
✅ Referential Transparency: Expressions that can be replaced by their values
✅ Higher-Order Functions: Functions that work with other functions
✅ Recursion: The functional alternative to loops
✅ Function Composition: Building complex operations from simple functions
✅ Practical Benefits: Better testability, composability, and maintainability
These concepts form the foundation for more advanced functional programming topics like monads, functional collections, and concurrent programming.
What's Next
In the next lesson, we'll dive deeper into higher-order functions and explore Scala's powerful collection methods like map
, filter
, fold
, and flatMap
. You'll learn how to manipulate and transform data using these functional tools effectively.
Comments
Be the first to comment on this lesson!