1. Overview

Imagine we have a collection of items and must find out if they meet a specific condition. These conditions are called predicates and are expressed as functions that take an input and return a Boolean.

We could roll up our sleeves and write loops or recursive functions to complete the job. But wait, there’s a better way! Scala 3 provides many built-in methods in its collectionsmaking this task a breeze. Why reinvent the wheel when Scala has already done the heavy lifting for us?

Methods like exists(), find(), forall(), filter(), count(), and contains() are easier to use and optimized for performance.

In this tutorial, we’ll explore using these Scala 3 methods to make our lives easier and our code more efficient.

2. Short-Circuiting Methods: Quick and Efficient

When we talk about short-circuiting methods, we mean methods that stop processing when they find what we’re looking for. This can be a huge time-saver, especially when dealing with significant inputs. In Scala 3, the methods exists(), find(), and forall() are our go-to options for short-circuiting predicate applications.

2.1. Using exists()

The exists() method is perfect for knowing if at least one element in our collection meets a specific condition. For example, let’s say we’ve a list of movie ratings and want to know if they are below 5:

scala> val movieRatings = List(8, 9, 7, 6, 5, 4)
val movieRatings: List[Int] = List(8, 9, 7, 6, 5, 4)

scala> val hasBadRating = movieRatings.exists(_ < 5)
val hasBadRating: Boolean = true

In this case, exists() will stop processing as soon as it finds the first rating below 5, making it efficient on inputs where the element is near the beginning of the collection.

2.2. Using find()

Similar to exists(), the find() method stops at the first element that satisfies the given condition. The difference is that find() returns an Option instead of a Boolean. The Option will contain the matching element if found or None, otherwise. Let’s say we’re looking for the first prime number in a list.

First, let’s define a simple isPrime function:

scala> import scala.util.boundary
     | def isPrime(n: Int): Boolean =
     |   boundary:
     |     for i <- 2 until n do
     |       if n % i == 0 then boundary.break(false)
     |     true
def isPrime(n: Int): Boolean

Now, we can use this function with find():

scala> val numbers = List(4, 6, 7, 9, 11)
     | val firstPrime = numbers.find(isPrime)
val numbers: List[Int] = List(4, 6, 7, 9, 11)
val firstPrime: Option[Int] = Some(7)

In this example, find will stop processing when it finds the first prime number, 7. We can get a Boolean by using the isDefined() method in the Option, but in that case, we might as well use exists(), find() comes in handy when we need the matched value instead of just a Boolean.

2.3. The Mathematical Relation Between forall() and exists()

Let’s dive into the forall() method, which can be counterintuitive when looking for the opposite of what it does. Suppose we have a list of ages and want to know if any minors are in the group. One might think to use exists() for this, but we can also use forAll with a negated predicate:

scala> val ages = List(21, 25, 30, 17, 18)
val ages: List[Int] = List(21, 25, 30, 17, 18)

scala> val hasMinors = !ages.forall(_ >= 18)
val hasMinors: Boolean = true

In this example, forall() checks if all elements in the list are greater than or equal to 18. By negating the result with !, we effectively check if any elements don’t meet the condition — if there are minors.

In mathematical terms, forall() and exists() are duals of each other. This means that for any predicate p, the following are equivalent:

  1. !list.exists(p) is equivalent to list.forAll(!p)
  2. !list.forAll(p) is equivalent to list.exists(!p)

In simpler terms, if we negate the predicate and switch between forall() and exists(), the result remains the same. This duality allows us to use either method depending on what makes the code more readable and understandable.

2.4. Checking for Specific Elements With contains()

We can use contains when we’re not looking for a condition but a specific element. This method is convenient when working with collections of complex types like case classes. Let’s consider a collection of Person case class instances:

scala> case class Person(name: String, age: Int)
     | val people = List(
     |   Person("Alice", 30),
     |   Person("Bob", 25),
     |   Person("Charlie", 40)
     | )
// defined case class Person
val people: List[Person] = List(Person(Alice,30), Person(Bob,25), Person(Charlie,40))

To find out if a specific person is on the list, let’s use contains():

scala> val personToFind = Person("Bob", 25)
     | val isPersonPresent = people.contains(personToFind)
val personToFind: Person = Person(Bob,25)
val isPersonPresent: Boolean = true

Here, contains() does the heavy lifting for us. It implicitly checks for equality based on the Person case class’s automatically generated equals method. This approach simplifies our code and eliminates the need for writing a custom predicate function.

3. Non-Short-Circuiting Operations

Unlike exists(), find(), forall(), and contains(), some methods always evaluate the entire collection, regardless of the predicate’s result. These methods continue processing even if a condition is met or not met early on because of the return value they deliver. Let’s explore some of these non-short-circuiting operations.

3.1. Counting Elements With count

The count() method traverses the entire collection to count the number of elements that satisfy the given predicate. It’s a classic example of a non-short-circuiting operation:

scala> val numbers = List(1, 2, 3, 4, 5)
     | val numberOfEvenNumbers = numbers.count(_ % 2 == 0)
val numbers: List[Int] = List(1, 2, 3, 4, 5)
val numberOfEvenNumbers: Int = 2

3.2. Creating New Strict or Lazy Collections With filter()

Another non-short-circuiting method is filter(). It goes through each element in the collection and applies the predicate to create a new collection containing only the entries that satisfy the condition. The original collection remains unchanged:

scala> val evenNumbers = numbers.filter(_ % 2 == 0)
val evenNumbers: List[Int] = List(2, 4)

In Scala, collections are strict by default, meaning the new collection will be created immediately by filter(). However, if we use lazy versions, the behavior changes radically.

When we use filter() with strict collections like List or Vector, the method processes the entire collection eagerly.

However, Scala 3 offers lazy collections like LazyList that change the game.

With LazyList, the filter() operation becomes lazy. This means elements are only processed as they are accessed, allowing for a similar short-circuiting behavior. For example, if we chain filter() with head(), the collection will only process elements until it finds the first one that satisfies the predicate.

Here’s a quick example:

scala> val numbers = LazyList.from(1)
     | val evenNumbers = numbers.filter(_ % 2 == 0)
     | val firstEven = evenNumbers.head
val numbers: LazyList[Int] = LazyList(1, 2, <not computed>)
val evenNumbers: LazyList[Int] = LazyList(2, <not computed>)
val firstEven: Int = 2

In this example, evenNumbers is an infinite lazy collection. When we call head(), it only forces the evaluation of elements until it finds the first even number. This lazy behavior can be beneficial when working with infinite or sufficiently large generated collections.

So, when performance is a concern and we’re dealing with large datasets, we should consider using LazyList to take advantage of lazy evaluation and achieve a short-circuiting effect.

4. Conclusion

In this tutorial, we’ve explored various ways to check if a predicate matches any element in a Scala 3 collection. While it might be tempting to roll our own logic, Scala 3 provides us with a rich set of tools that are both efficient and easy to use. From short-circuiting methods like exists(), find(), and contains(), to non-short-circuiting ones like count() and filter(), we have various options.

We also delved into the power of lazy collections like LazyList, which can change the behavior of methods like filter() to mimic short-circuiting, especially when dealing with large or infinite collections.

In summary, Scala 3 equips us with all the tools to match predicates against collections efficiently. So, the next time we need to check elements against a condition, we should remember that Scala 3 has us covered.

Comments are closed on this article!