
Learn through the super-clean Baeldung Pro experience:
>> Membership and Baeldung Pro.
No ads, dark-mode and 6 months free of IntelliJ Idea Ultimate to start with.
Last updated: March 18, 2024
In this tutorial, we’ll explore four different approaches to solve the same problem – how to reverse the elements of a sequence.
We’ll see four ways to solve the problem to understand how different criteria can lead to different solutions.
Our first approach is to rely on the excellent Scala collections library.
The Scala documentation for collections contains a method that does what we need in the trait Seq, with the following signature:
def reverse: Seq[A]
As long as the collection we want to reverse (List, for example) inherits, directly or indirectly, from that trait, we can invoke the method to obtain the reversed collection:
// say we have a list of integers:
val integers = 2 :: 4 :: 6 :: 7 :: 8 :: Nil
// and we want to reverse it:
val reversed = integers.reverse
Although the collections library offers a fast and easy way to solve the problem, we want to implement the method and leverage the opportunity to learn some idiomatic code. Our second approach uses a simple recursive method:
def naiveRecursiveReverse[T](xs: Seq[T]): Seq[T] =
if (xs.isEmpty) xs
else naiveRecursiveReverse(xs.drop(1)) :+ xs.head
Let’s observe a few things:
The previous attempt is straightforward to understand but could be more efficient and potentially safe. To avoid overflowing the stack, we can use the technique known as tail recursion.
def tailRecursiveReverse[T](xs: Seq[T]): Seq[T] = {
@tailrec
def aux(acc: Seq[T], sequence: Seq[T]): Seq[T] =
if (sequence.isEmpty) acc
else aux(sequence.head +: acc, sequence.tail)
aux(Seq.empty[T], xs)
}
This time, we declare an inner auxiliary method, aux, where we’ll apply our tail recursion. As we can see, it receives an additional parameter we use as an accumulator. This pattern is typical of tail recursive functions. Because of that, we can be sure that the method is the very last thing that is called (hence the name: tail recursion).
Another interesting detail of this snippet is the use of the annotation @tailrec. It makes no difference in the compiled code but forces the compiler to ensure this method is tail-recursive. If that’s not the case, the code won’t compile.
We want to provide a tail-recursive method because the compiler can transform methods of such type into iterations. As a result, the stack doesn’t need to grow with each invocation. Now we know that reversing big collections doesn’t provoke memory exhaustion issues.
The pattern used in our previous attempt is very common. So common, indeed, that it receives its own name: folding. Also, there are methods in the standard collections to do it. But what is folding?
In simple terms, we can imagine that we have a very long sheet of paper, and we want to fold it in a certain way, starting at one of the extremes, and keep doing that until the whole sheet comprises all the original elements of the sheet, applying the folding operation.
In our case, we want to take our sequence and fold it: for each element, we want to prepend it to another sequence (initially empty). The resulting code is simple:
def foldBasedReverse[T](xs: Seq[T]): Seq[T] =
xs.foldLeft(Seq.empty[T])((sequence, element) => element +: sequence)
This has all the advantages of the tail-recursive method but is simpler to write. All we have to do is define the initial state of the folded result (an empty sequence for us) and a function that transforms an intermediate result and an element from the original collection into a new intermediate result.
We can test the functionality of our methods using the powerful ScalaTest framework. Let’s start with two generic approaches that test reversing small and big collections, respectively:
import ListReverser._
def testReverseSmallList(f: Seq[_] => Seq[_]): Assertion = {
val list = List(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
val expectedReversedList = List(10, 9, 8, 7, 6, 5, 4, 3, 2, 1)
val actualReversedList = f(list)
assertResult(expectedReversedList)(actualReversedList)
}
def testReverseBigList(f: Seq[_] => Seq[_]): Assertion = {
val n = 100_000
val vector: Vector[String] =
(0 to n).foldLeft(Vector.empty[String])((v, _) =>
v.appended(Random.nextString(1000))
)
val expectedFirstElement: String = vector.last
val actualFirstElement: String =
f(vector).head.asInstanceOf[String]
assertResult(expectedFirstElement)(actualFirstElement)
}
We want to use the same methods to test the three implementations that we wrote. For that reason, we pass to them the function that reverses, called f.
With the above in place, the actual tests become trivial:
"The naive list reverser" should {
val reversingFunction: Seq[_] => Seq[_] = naiveRecursiveReverse
"reverse small lists" in {
testReverseSmallList(reversingFunction)
}
"exhaust memory with big lists" in {
assertThrows[StackOverflowError](testReverseBigList(reversingFunction))
}
}
"The tail-recursive list reverser" should {
val reversingFunction: Seq[_] => Seq[_] = tailRecursiveReverse
"reverse small lists" in {
testReverseSmallList(reversingFunction)
}
"handle big lists" in {
testReverseBigList(reversingFunction)
}
}
"The folding list reverser" should {
val reversingFunction: Seq[_] => Seq[_] = foldBasedReverse
"reverse small lists" in {
testReverseSmallList(reversingFunction)
}
"handle big lists" in {
testReverseBigList(reversingFunction)
}
}
In this article, we’ve learned several ways to reverse the elements of a sequence. As is often the case, in general, we should prefer using the collections library because the code is very performant and thoroughly tested.
Nonetheless, by providing our own implementation, we can learn or refresh interesting, idiomatic, and efficient patterns in our coding. Also, variations can be created easily; for example, to fold a collection starting from the right end instead of the left one.