1. Introduction

ZIO Prelude is a powerful library that helps developers handle the complexities of abstraction. Like other libraries, it builds on top of the already robust features of Scala, providing a more-or-less opinionated system and a particular way of doing things.

In contrast, what’s different in ZIO Prelude compared to other libraries is that its abstractions are more basic. The reason for that is that it’s algebraic (meaning that the abstractions should describe algebraic properties), compositional (meaning that the abstractions should be applicable independently), and lawful (meaning that the abstractions should be defined in terms of laws that can be verified).

In addition to the functional abstractions in this article, ZIO Prelude offers functional data types to make our lives easier. It provides a foundation for creating new types that safely complement the functional abstractions and gives us a generic representation of a computation that uses these concepts.

In this article, we’ll explore some of the functional abstractions provided by the library and see how we can use them to produce compact, easy-to-understand, reliable, and performant code.

2. Installation

What we need to do before we can use ZIO Prelude in our projects is very simple: add a dependency to the ZIO Prelude library in our build.sbt file that looks like this:

libraryDependencies += "dev.zio" %% "zio-prelude" % "1.0.0-RC23"

3. Approaches to Functional Abstraction

Before delving into the “functional abstractions” offered by ZIO Prelude, we should understand what we mean by that in this context of software development.

Abstraction is a process that obtains general rules and concepts from a set of more specific examples. We can think of it as a classification or categorization of things. In this case, of functions. It allows developers to encapsulate common patterns and operations, promoting code reuse and maintainability.

At its core, it involves abstracting implementation details and focusing on what needs to be done rather than how it’s done, which paves the way towards a declarative programming style, as opposed to an imperative one.

3.1. Functor-based Hierarchies

Traditional functional abstractions are often based on the concept of functors, which are essentially data structures that can be mapped over.

Functors provide a way to apply a function to each element within the structure, allowing for transformations while preserving the structure’s shape. This notion rapidly leads, using applicative functors, to the notion of monads, which are the most popular construct in functional programming for chaining computations over structures.

3.2. Algebra-based Hierarchies

There’s another system of concepts, closely related to the above, that provides a different approach and possibly more control for the developer: an algebraic system. It gives control over the properties of the sets upon which the programs operate.

As the foundation of this system, basic algebraic properties are defined. Conventionally, algebraic systems quickly browse over the basic properties to focus as soon as possible on set-based constructs. A set with an associative operation is a semi-group. A semi-group with an identity element is a monoid. A monoid where all elements of the set have an inverse element is a group, and so on.

In essence, there are several fundamental properties of the set and the operators that are easier to understand and reason about. Are the operations associative? Are they commutative? Is there an identity element? It is these properties that Prelude ZIO enforces, rather than the more abstract ones described just before.

The main advantage of this approach concerning the more abstract ones is that it’s more intuitive. Many people (at least in technical fields) have had exposure to the relatively simple concepts of associativity, commutativity, identity elements, etc. Only a few people firmly grasp the notions of semi-groups, monoids, groups, and even monads.

4. Levels of Abstraction in ZIO Prelude

Abstraction in ZIO Prelude rests on the more basic concept of type classes.

4.1. A Quick Refresher on Type Classes

As a refresher, type classes are a way to enable ad-hoc polymorphism in types that are not otherwise related. It allows different data types to show common behavior without requiring inheritance or the explicit implementation of interfaces.

A typical example of type classes is the Printable type class:

/**
 * Type class that instructs implementing types to be able to be formatted as strings.
 *
 * @tparam A type that needs to implement the trait
 */
trait Printable[A] {
  /**
   * Any type A that implements this trait must be able to present itself as a string.
   *
   * @param value instance of type [[A]] that implements the type class
   * @return string representation of the parameter `value`
   */
  def format(value: A): String
}

/**
 * Provides implementations of the [[Printable]] type class for types [[String]] and [[Int]].
 */
object Printable {

  /**
   * Implementation of the [[Printable]] type class for [[String]].
   */
  implicit val stringPrintable: Printable[String] = (value: String) => value

  /**
   * Implementation of the [[Printable]] type class for [[Int]].
   */
  implicit val intPrintable: Printable[Int] = (value: Int) => value.toString
}

Here is a straightforward example of how to use it (remember to import the implementations in the object Printable):

object Demo extends App {
  private def printAny[A](value: A)(implicit printable: Printable[A]): Unit = {
    println(printable.format(value))
  }

  printAny("Hello") // Prints "Hello"
  printAny(10) // Prints "10"
}

This is all good and nice, but we can do even better with ZIO Prelude.

The ZIO Prelude library provides two levels of functional abstraction: using type classes directly and generic programming. Let’s explore them separately.

4.2. ZIO Prelude Using Type Classes

Let’s imagine that we want to calculate the average of many numbers. To make it faster, we split those numbers into bunches by combining the partial averages with a new, all-encompassing average. The problem is that the average operation is not associative!

class CombinedAverageTest extends AnyWordSpec {
  val avg1: Double = (2.0 + 3.0 + 4.0) / 3 // avg1 is 3.0
  val avg2: Double = (10.0 + 11.0) / 2 // avg2 is 10.5

  "a simple average" should {
    "sum its elements and divide them by their count" in {
      assertResult(3.0)(avg1)
      assertResult(10.5)(avg2)
    }
    "not be associative" in {
      val avgAvg = (avg1 + avg2) / 2

      // if "average" were associative, the average of the two averages
      // would be 6.0, but it's 6.75 instead!
      assert(avgAvg != 6.0)
    }
  }
}

We need a smarter way to combine the averages. ZIO Prelude to the rescue:

/**
 * A combined average class that takes into consideration the need to carry the count of elements.
 *
 * @param sum   average of elements
 * @param count number of elements
 */
case class CombinedAverage(sum: Double, count: Int)

object CombinedAverage {
  implicit val AvgAssociative: Associative[CombinedAverage] = {
    new Associative[CombinedAverage] {
      override def combine(l: => CombinedAverage, r: => CombinedAverage): CombinedAverage = {
        val sum = l.sum * l.count + r.sum * r.count
        val count = l.count + r.count
        CombinedAverage(sum / count, count)
      }
    }
  }
}

We want the average to become associative, so we are providing an implementation of the type class Associative[CombinedAverage]. This new implementation takes into consideration the nuances of combining averages.

Now, we can elegantly, reliably, and efficiently combine averages:

class CombinedAverageTest extends AnyWordSpec {
  val avg1: Double = (2.0 + 3.0 + 4.0) / 3 // avg1 is 3.0
  val avg2: Double = (10.0 + 11.0) / 2 // avg2 is 10.5

  "a smarter average" should {
    "properly combine partial averages" in {
      val avg1 = CombinedAverage(3.0, 3)
      val avg2 = CombinedAverage(10.5, 2)
      val avgAvg = avg1 <> avg2
      assertResult(CombinedAverage(6.0, 5))(avgAvg)
    }
  }
}

That lovely <> operator is some syntactic sugar that ZIO Prelude adds to make our lives even easier.

Another example worth looking at (taking from the official documentation) is this: let’s imagine we are conducting a vote (poll) in the technical community to find out which implementation of an http server is the most favored one: zio-http, uzi-http, or zio-tls-http (we’ll call these options topics). We want to represent the results as a map, where the keys are topics and the values are the number of voters.

That by itself poses a small challenge. However, when we add the requirement that we want to combine the results from diverse regions, things get more arduous. We aren’t going to implement a map-combining algorithm here because that’s well documented elsewhere, it’s cumbersome, it’s beyond the scope of this article, and we don’t need to. Let’s ZIO Prelude do the heavy lifting for us:

object VotingExample extends scala.App {

  object Votes extends Subtype[Int] {
    implicit val associativeVotes: Associative[Votes] =
      new Associative[Votes] {
        override def combine(l: => Votes, r: => Votes): Votes =
          Votes(l + r)
      }
  }

  private type Votes = Votes.Type

  object Topic extends Subtype[String]

  private type Topic = Topic.Type

  final case class VoteState(map: Map[Topic, Votes]) {
    self =>
    def combine(that: VoteState): VoteState =
      VoteState(self.map combine that.map)

  }

  private val zioHttp = Topic("zio-http")
  private val uziHttp = Topic("uzi-http")
  private val zioTlsHttp = Topic("zio-tls-http")

  private val leftVotes = VoteState(Map(zioHttp -> Votes(4), uziHttp -> Votes(2)))
  private val rightVotes = VoteState(Map(zioHttp -> Votes(2), zioTlsHttp -> Votes(2)))

  println(leftVotes combine rightVotes)
}

The last line of the example shows how concise programming can be. Both leftVotes and rightVotes are VoteState objects, i.e., voting results, from different regions. If we focus on defining the VoteState case class, we’ll notice that it contains a map where the keys are Topic and values are Votes. A fascinating part of this case class is how it combines two VoteState by letting ZIO Prelude combine the two corresponding maps!

Another interesting aspect of this example is how Topic and Votes are defined. The detailed definition and value of that approach (called New Types) are beyond the scope of this article, but they allow for safer types without a performance penalty. They let us have types of Int, for example, so we don’t mix them by mistake.

Finally, for the combination of maps to work, we need to provide a way to combine the values. ZIO Prelude entirely handles the rest. In this case, the votes for one topic in one region get added to those for the same topic in a different region.

4.3. ZIO Prelude Using Generic Programming

In addition to all the above, it’s possible to add one more level of abstraction. If we apply generic programming to the type classes, we can abstract the data structures upon which the operations are performed.

Say that we want to count the number of words in a list of strings (again, this particular example comes from the library’s documentation). Each string can have an arbitrary number of words separated by spaces. We could do something like this:

def wordCount(lines: List[String]): Int =
  lines.map(_.split(" ").length).sum

There’s not much to say; it’s easy to understand. What’s interesting is that this is a typical map-reduce operation. Then, we can abstract the map operation to get something with a signature like this:

def mapReduce[A, B](as: List[A])(f: A => B)(implicit identity: Identity[B]): B = ???

This is nice because we can use it with any mapping function we want, just as long as the data structure is a list. But we can abstract the data structure as well, and then the signature could look like this:

def mapReduce[F[+_]: ForEach, A, B: Identity](as: F[A])(f: A => B): B = ???

The ForEach abstraction is a mechanism that iterates through arbitrary collections without modifying the collection structure.

Now, we have a very versatile, powerful, and elegant signature. Unfortunately, it is also challenging for the uninitiated to read. Luckily, ZIO Prelude is very versatile and accommodates diverse styles and desired levels of abstraction.

5. Conclusion

In this article, we’ve scratched the surface of the powerful ZIO Prelude library’s approach to functional abstractions. One of its design principles is not imposing particular styles upon the programmers. It can be used as an inspirational tool, or it can be adopted as a full-abstraction support tool—or anything in between.

As usual, the full source code for this article is over on GitHub.

Subscribe
Notify of
guest
0 Comments
Inline Feedbacks
View all comments