Loops in Scala: for and while

Introduction

Iteration is a fundamental programming concept, but Scala approaches it differently than many imperative languages. While Scala supports traditional while loops, it encourages a more functional approach through for-comprehensions, which are more expressive and safer than traditional loops.

In this lesson, you'll learn both approaches to iteration in Scala, understand when to use each, and discover how for-comprehensions can make your code more readable and less error-prone.

while Loops: The Traditional Approach

Basic while Loop

The while loop in Scala works similarly to other languages:

var i = 0
while (i < 5) {
  println(s"Iteration $i")
  i += 1
}
// Prints: Iteration 0, Iteration 1, ..., Iteration 4

do-while Loop

Scala also supports do-while loops:

var input = ""
do {
  print("Enter 'quit' to exit: ")
  input = scala.io.StdIn.readLine()
  println(s"You entered: $input")
} while (input != "quit")

Practical while Loop Example

import scala.util.Random

def findRandomTarget(target: Int): Int = {
  val random = new Random()
  var attempts = 0
  var current = 0

  while (current != target) {
    current = random.nextInt(10) + 1  // Random number 1-10
    attempts += 1
    println(s"Attempt $attempts: Generated $current")
  }

  println(s"Found $target after $attempts attempts!")
  attempts
}

findRandomTarget(7)

for-Comprehensions: The Scala Way

For-comprehensions are Scala's idiomatic way to iterate. They're more powerful and safer than traditional loops.

Basic for Loop

// Simple range iteration
for (i <- 1 to 5) {
  println(s"Number: $i")
}

// Using until (exclusive end)
for (i <- 1 until 5) {
  println(s"Number: $i")  // Prints 1, 2, 3, 4
}

// Iterating over collections
val fruits = List("apple", "banana", "cherry")
for (fruit <- fruits) {
  println(s"I like $fruit")
}

for with yield: Generating New Collections

The real power of for-comprehensions comes with yield:

val numbers = 1 to 10

// Transform elements
val squares = for (n <- numbers) yield n * n
println(squares)  // Vector(1, 4, 9, 16, 25, 36, 49, 64, 81, 100)

// Work with strings
val names = List("alice", "bob", "charlie")
val capitalized = for (name <- names) yield name.capitalize
println(capitalized)  // List(Alice, Bob, Charlie)

// More complex transformations
val words = List("hello", "world", "scala")
val lengths = for (word <- words) yield s"$word has ${word.length} letters"
println(lengths)
// List(hello has 5 letters, world has 5 letters, scala has 5 letters)

Guards: Filtering with if

Add conditions to filter elements:

val numbers = 1 to 20

// Filter even numbers
val evenNumbers = for (n <- numbers if n % 2 == 0) yield n
println(evenNumbers)  // Vector(2, 4, 6, 8, 10, 12, 14, 16, 18, 20)

// Multiple conditions
val specialNumbers = for (
  n <- numbers 
  if n % 2 == 0  // even
  if n > 10      // greater than 10
) yield n
println(specialNumbers)  // Vector(12, 14, 16, 18, 20)

// Filter and transform
val words = List("apple", "banana", "apricot", "cherry", "avocado")
val aWords = for (
  word <- words 
  if word.startsWith("a")
) yield word.toUpperCase
println(aWords)  // List(APPLE, APRICOT, AVOCADO)

Nested Loops

For-comprehensions handle nested iteration elegantly:

// Traditional nested loops equivalent
val coordinates = for (
  x <- 1 to 3
  y <- 1 to 3
) yield (x, y)
println(coordinates)
// Vector((1,1), (1,2), (1,3), (2,1), (2,2), (2,3), (3,1), (3,2), (3,3))

// Multiplication table
val multiplicationTable = for (
  i <- 1 to 5
  j <- 1 to 5
) yield s"$i × $j = ${i * j}"

multiplicationTable.foreach(println)

Variable Binding in for-Comprehensions

You can introduce intermediate variables:

val words = List("hello", "world", "scala", "programming")

val analysis = for (
  word <- words
  length = word.length  // Bind intermediate value
  if length > 4
) yield s"$word ($length chars)"

println(analysis)
// List(hello (5 chars), world (5 chars), scala (5 chars), programming (11 chars))

// Multiple bindings
val numbers = 1 to 10
val calculations = for (
  n <- numbers
  doubled = n * 2
  squared = n * n
  if doubled < squared
) yield s"$n: doubled=$doubled, squared=$squared"

println(calculations)

Working with Different Collection Types

Lists and Arrays

val fruits = List("apple", "banana", "cherry")
val numbers = Array(1, 2, 3, 4, 5)

// Lists
val fruitLengths = for (fruit <- fruits) yield fruit.length
println(fruitLengths)  // List(5, 6, 6)

// Arrays
val evenNumbers = for (n <- numbers if n % 2 == 0) yield n
println(evenNumbers.toList)  // List(2, 4)

// Convert between types
val listFromArray = for (n <- numbers) yield n * 10
println(listFromArray.getClass)  // class [I (Array)

Ranges

// Different range types
val range1 = 1 to 10        // 1, 2, 3, ..., 10
val range2 = 1 until 10     // 1, 2, 3, ..., 9
val range3 = 1 to 10 by 2   // 1, 3, 5, 7, 9

// Reverse ranges
val countdown = for (i <- 10 to 1 by -1) yield i
println(countdown)  // Vector(10, 9, 8, 7, 6, 5, 4, 3, 2, 1)

// Character ranges
val alphabet = for (c <- 'a' to 'z') yield c
println(alphabet.mkString)  // abcdefghijklmnopqrstuvwxyz

Maps

val grades = Map("Alice" -> 95, "Bob" -> 87, "Charlie" -> 92)

// Iterate over map entries
for ((name, grade) <- grades) {
  println(s"$name scored $grade")
}

// Filter and transform
val highPerformers = for (
  (name, grade) <- grades
  if grade >= 90
) yield s"$name (${grade}%)"

println(highPerformers)  // Set(Alice (95%), Charlie (92%))

Advanced for-Comprehension Patterns

Pattern Matching in for-Comprehensions

case class Person(name: String, age: Int, city: String)

val people = List(
  Person("Alice", 30, "New York"),
  Person("Bob", 25, "San Francisco"),
  Person("Charlie", 35, "New York"),
  Person("Diana", 28, "Chicago")
)

// Extract values using pattern matching
val newYorkers = for (
  Person(name, age, "New York") <- people
) yield s"$name ($age)"

println(newYorkers)  // List(Alice (30), Charlie (35))

// Partial pattern matching with guards
val youngAdults = for (
  Person(name, age, city) <- people
  if age < 30
) yield s"$name from $city"

println(youngAdults)  // List(Bob from San Francisco, Diana from Chicago)

Working with Options

val maybeNumbers = List(Some(1), None, Some(3), Some(4), None)

// Extract values from Some
val actualNumbers = for (
  Some(number) <- maybeNumbers
) yield number

println(actualNumbers)  // List(1, 3, 4)

// More complex example
case class User(id: Int, name: String, email: Option[String])

val users = List(
  User(1, "Alice", Some("alice@example.com")),
  User(2, "Bob", None),
  User(3, "Charlie", Some("charlie@test.com"))
)

val emailList = for (
  User(id, name, Some(email)) <- users
) yield s"$name <$email>"

println(emailList)  // List(Alice <alice@example.com>, Charlie <charlie@test.com>)

Flattening Nested Collections

val nestedNumbers = List(List(1, 2), List(3, 4, 5), List(6))

// Flatten and transform
val allNumbers = for (
  innerList <- nestedNumbers
  number <- innerList
) yield number * 2

println(allNumbers)  // List(2, 4, 6, 8, 10, 12)

// Real-world example: processing grouped data
case class Department(name: String, employees: List[String])

val departments = List(
  Department("Engineering", List("Alice", "Bob", "Charlie")),
  Department("Marketing", List("Diana", "Eve")),
  Department("Sales", List("Frank", "Grace", "Henry"))
)

val allEmployees = for (
  dept <- departments
  employee <- dept.employees
) yield s"$employee (${dept.name})"

allEmployees.foreach(println)

Practical Examples

Example 1: Data Processing Pipeline

case class Sale(product: String, quantity: Int, price: Double, region: String)

val sales = List(
  Sale("Laptop", 5, 999.99, "North"),
  Sale("Mouse", 20, 25.50, "South"),
  Sale("Keyboard", 15, 75.00, "North"),
  Sale("Monitor", 8, 299.99, "East"),
  Sale("Laptop", 3, 999.99, "South")
)

// Complex data processing with for-comprehension
val report = for (
  sale <- sales
  revenue = sale.quantity * sale.price
  if revenue > 1000  // Only high-value sales
  if sale.region == "North" || sale.region == "South"
) yield {
  val formatted = f"${sale.product}%-10s: ${sale.quantity}%3d units, $$${revenue}%8.2f"
  (sale.region, formatted)
}

// Group by region
val grouped = report.groupBy(_._1).view.mapValues(_.map(_._2))
grouped.foreach { case (region, sales) =>
  println(s"\n$region Region:")
  sales.foreach(sale => println(s"  $sale"))
}

Example 2: Game Board Generation

sealed trait Cell
case object Empty extends Cell
case object Wall extends Cell
case object Player extends Cell

def generateMaze(width: Int, height: Int): Array[Array[Cell]] = {
  val maze = Array.ofDim[Cell](height, width)

  // Initialize with walls
  for (
    row <- 0 until height
    col <- 0 until width
  ) {
    maze(row)(col) = if (row == 0 || row == height - 1 || 
                         col == 0 || col == width - 1) Wall else Empty
  }

  // Add some internal walls
  for (
    row <- 2 until height - 2 by 2
    col <- 2 until width - 2 by 2
    if scala.util.Random.nextBoolean()
  ) {
    maze(row)(col) = Wall
  }

  // Place player
  maze(1)(1) = Player

  maze
}

def printMaze(maze: Array[Array[Cell]]): Unit = {
  for (row <- maze) {
    val line = for (cell <- row) yield cell match {
      case Empty => " "
      case Wall => "#"
      case Player => "@"
    }
    println(line.mkString)
  }
}

val maze = generateMaze(15, 10)
printMaze(maze)

Example 3: Text Analysis

def analyzeText(text: String): Map[String, Any] = {
  val words = text.toLowerCase.split("\\W+").filter(_.nonEmpty)

  val wordFrequency = for (
    word <- words.distinct
  ) yield word -> words.count(_ == word)

  val longWords = for (
    word <- words.distinct
    if word.length > 5
  ) yield word

  val vowelCounts = for (
    word <- words.distinct
    vowelCount = word.count("aeiou".contains(_))
  ) yield word -> vowelCount

  Map(
    "totalWords" -> words.length,
    "uniqueWords" -> words.distinct.length,
    "averageLength" -> words.map(_.length).sum.toDouble / words.length,
    "longWords" -> longWords.sorted,
    "mostCommon" -> wordFrequency.maxBy(_._2),
    "vowelRich" -> vowelCounts.filter(_._2 >= 3).sortBy(-_._2)
  )
}

val sampleText = """
  Scala is a powerful programming language that combines object-oriented 
  and functional programming paradigms. It runs on the Java Virtual Machine 
  and provides excellent interoperability with Java libraries.
"""

val analysis = analyzeText(sampleText)
analysis.foreach { case (key, value) =>
  println(s"$key: $value")
}

Performance Considerations

for-Comprehensions vs Traditional Loops

import scala.collection.mutable.ArrayBuffer

// Traditional imperative approach
def processImperative(numbers: List[Int]): List[Int] = {
  val result = ArrayBuffer[Int]()
  var i = 0
  while (i < numbers.length) {
    val n = numbers(i)
    if (n % 2 == 0 && n > 10) {
      result += n * n
    }
    i += 1
  }
  result.toList
}

// Functional approach with for-comprehension
def processFunctional(numbers: List[Int]): List[Int] = {
  for (
    n <- numbers
    if n % 2 == 0
    if n > 10
  ) yield n * n
}

// Both approaches are equivalent but functional is more readable
val numbers = (1 to 100).toList
println(processImperative(numbers))
println(processFunctional(numbers))

Choosing the Right Approach

// Use while loops for:
// - Performance-critical code where you need precise control
// - Stateful operations that don't map well to functional style
def findFirstOccurrence(text: String, pattern: String): Int = {
  var index = 0
  while (index <= text.length - pattern.length) {
    if (text.substring(index, index + pattern.length) == pattern) {
      return index
    }
    index += 1
  }
  -1
}

// Use for-comprehensions for:
// - Data transformation and filtering
// - Working with collections
// - When readability is important
def extractEmailDomains(emails: List[String]): List[String] = {
  for (
    email <- emails
    if email.contains("@")
    domain = email.split("@")(1)
    if domain.contains(".")
  ) yield domain.toLowerCase
}

Common Patterns and Idioms

Generating Combinations

// Generate all pairs
val numbers = List(1, 2, 3, 4)
val pairs = for (
  i <- numbers
  j <- numbers
  if i < j
) yield (i, j)
println(pairs)  // List((1,2), (1,3), (1,4), (2,3), (2,4), (3,4))

// Generate Cartesian product
val colors = List("red", "green", "blue")
val sizes = List("small", "medium", "large")
val products = for (
  color <- colors
  size <- sizes
) yield s"$size $color"
println(products)

Conditional Processing

def processData(data: List[Int], condition: String): List[Int] = {
  for (
    n <- data
    result <- condition match {
      case "double" => Some(n * 2)
      case "square" => Some(n * n)
      case "positive" => if (n > 0) Some(n) else None
      case _ => None
    }
  ) yield result
}

println(processData(List(-2, -1, 0, 1, 2), "positive"))  // List(1, 2)
println(processData(List(1, 2, 3), "square"))            // List(1, 4, 9)

Best Practices

1. Prefer for-Comprehensions for Collection Processing

// Good: Readable and functional
val processed = for (
  item <- items
  if item.isValid
  transformed = item.transform()
  if transformed.nonEmpty
) yield transformed

// Avoid: Imperative style when functional is clearer
var result = List.empty[String]
for (item <- items) {
  if (item.isValid) {
    val transformed = item.transform()
    if (transformed.nonEmpty) {
      result = result :+ transformed
    }
  }
}

2. Use Guards for Complex Filtering

// Good: Clear intention
val validUsers = for (
  user <- users
  if user.age >= 18
  if user.isActive
  if user.email.isDefined
) yield user

// Less clear: Combining conditions
val validUsers2 = for (
  user <- users
  if user.age >= 18 && user.isActive && user.email.isDefined
) yield user

3. Break Complex Operations into Steps

// Good: Clear steps
val result = for (
  data <- rawData
  cleaned = data.trim.toLowerCase
  if cleaned.nonEmpty
  parsed = parseData(cleaned)
  if parsed.isSuccess
) yield parsed.get

// Avoid: Everything in one expression
val result2 = for (
  data <- rawData
  if data.trim.toLowerCase.nonEmpty && parseData(data.trim.toLowerCase).isSuccess
) yield parseData(data.trim.toLowerCase).get

Summary

In this lesson, you've learned about iteration in Scala:

while Loops: Traditional imperative iteration for specific use cases
for-Comprehensions: Scala's functional approach to iteration
yield: Generating new collections from existing ones
Guards: Filtering elements with conditions
Pattern Matching: Destructuring data in loops
Nested Iteration: Handling multiple collections elegantly
Best Practices: When to use each approach for maximum clarity

For-comprehensions are one of Scala's most elegant features, making collection processing more readable and less error-prone than traditional loops. They're a stepping stone to understanding more advanced functional programming concepts.

What's Next

In the next lesson, we'll explore "The Scala Toolkit: Your First Essential Library." You'll get an introduction to the Scala Toolkit, a curated set of libraries for common tasks like reading files, parsing JSON, and making HTTP requests.

This will be your first taste of Scala's rich ecosystem and how it makes common programming tasks easier and more enjoyable.

Ready to expand your toolkit? Let's continue!