Blog

Why Learn Contextual Abstractions in Scala 3 First

01 Feb, 2023
Xebia Background Header Wave

TL;DR: Scala 2 is still the most used version of Scala, according to the Scala Developer survey. This means that context abstractions are still giving headaches to Scala developers, especially newcomers. This blog introduces contextual abstractions, first from a Scala 3 point of view, profiting from the API overhaul. Then we will drive home the intuition of contextual abstractions with some code examples, and conclude with the relationship with Scala 2, thus using the design principles of the Scala 3 contextual abstractions when working on Scala 2 codebases to avoid pitfalls and detect code smells, and also to make it easier to digest.

What are Contextual Abstractions

Scala’s implicit is one of the language’s most important features. It is the fundamental tool used to abstract over context, having lots of use cases like implementing type classes, dependency injection, expressing capabilities, computing new types, and more. Other popular languages such as Haskell, Rust, and Swift implement the same idea.

The core intuition for contextual abstractions is the idea of term inference; given a type, the compiler can create a standard object that has that type.

Critiques

  • They are too powerful, and they can be easily misused or overused.
  • Abuse of implicit imports often leads to difficult-to-track errors.
  • The syntax of contextual abstractions is minimal; it consists of a single implicit modifier that can be attached to many language constructs. This means that it is confusing for newcomers to use.
  • Context functions pose a challenge to tools like Scaladocs, which are based on static web pages.
  • Failed context functions searches provide unhelpful messages, particularly if some deeply recursive implicit search has failed.

However, none of these represent a fatal problem, as current Scala developers have become accustomed to it. This status quo represents a big hurdle for normies.

How is it new in Scala 3

Scala 3 introduces four fundamental changes for contextual abstractions:

  1. Given Instances : New way of defining “canonical” values of a type.
  2. Using Clauses : This means implicit arguments defined with the using keyword.
  3. Given Imports : A special form of import wildcard selector is used to import given instances.
  4. Implicit Conversions : Implicit conversions are defined by given instances of the scala.Conversion class.

These design fundamentals avoid interaction between other language features and provide more orthogonality as they separate better term inference from other language APIs. Driving the point home, the API overhaul consists of (not extensive):

  • A single way of defining givens with an extension keyword for method extension, and there is a single way of introducing implicit parameters and arguments
  trait Ord[T]:
  def compare(x: T, y: T): Int
  extension (x: T) 
    def < (y: T) = compare(x, y) < 0
    def > (y: T) = compare(x, y) > 0

    given intOrd: Ord[Int] with
    def compare(x: Int, y: Int) =
        if x < y then -1 else if x > y then +1 else 0

    given listOrd[T](using ord: Ord[T]): Ord[List[T]] with

    def compare(xs: List[T], ys: List[T]): Int = (xs, ys) match
        case (Nil, Nil) => 0
        case (Nil, _) => -1
        case (_, Nil) => +1
        case (x :: xs1, y :: ys1) =>
        val fst = ord.compare(x, y)
        val extensionUsage = x > y 
        if fst != 0 then fst else compare(xs1, ys1)
  • There is a single way of importing implicit values
     object A:
        class TC
        given tc: TC = ???
        def f(using TC) = ???

    object B:
        import A.*
        import A.given
  • And for the used and abused implicit conversions, succinct intention
import scala.Conversion
abstract class Conversion[-T, +U] extends (T => U):
  def apply (x: T): U
given Conversion[String, Token] with
  def apply(str: String): Token = new KeyWord(str)
given Conversion[String, Token] = new KeyWord(_)

Code Examples

Now let’s dive deeper into contextual abstractions by playing around with some code and Typeclasses. If you want the full details, make sure to clone the fp-fundamentals repo to follow along better.

Step 0: Scaffolding

As you can see, the following code is imperative and it will print to console the account balance. We will use functional programming and type classes to improve this code snippet.

@main def hello: Unit =
  def getBalanceBank1: Int = 100
  def getBalanceBank2: Int = 80
  def balance: Int =
    val b1: Int = getBalanceBank1
    val b2: Int = getBalanceBank2
    b1 + b2
  // EDGE OF THE WORLD
  println(balance)

Step 1: Create data type Maybe

Let’s create a Maybe data type to better express the querying from the def getBalance... method. Let us also update the program.

sealed trait Maybe[+A]
  case class Yes[A](a: A) extends Maybe[A]
  case object No extends Maybe[Nothing]
@main def hello: Unit =
- def getBalanceBank1: Int = 100
- def getBalanceBank2: Int = 80
+ def getBalanceBank1: Maybe[Int] = Yes(100)
+ def getBalanceBank2: Maybe[Int] = Yes(80)
  def balance: Int =
-   val b1: Int = getBalanceBank1
-   val b2: Int = getBalanceBank2
    b1 + b2
+   val b1: Maybe[Int] = getBalanceBank1
+   val b2: Maybe[Int] = getBalanceBank2
    b1 + b2 // it will fail
  // EDGE OF THE WORLD
  println(balance)

Step 2: Create type class Combinable

We create a combinable type class to combine the previously defined Maybe type class. In the instances object we will create the given instances.

  trait Combinable[A]:
   def combine(x: A, y: A): A

  given Combinable[Int] with
    override def combine(x: Int, y: Int): Int = x + y

  given [A: Combinable]: Combinable[Maybe[A]] with
    override def combine(x: Maybe[A], y: Maybe[A]): Maybe[A] = (x, y) match {
       ...
    }

And now our code will work by updating the program to summon[Combinable[Maybe[Int]]].

+ package training
+ import training.DataTypes.*
+ import training.Typeclases.*
+ import training.Instances.given

@main def hello: Unit =
  def getBalanceBank1: Maybe[Int] = Yes(100)
  def getBalanceBank2: Maybe[Int] = Yes(80)
-  def balance: Int =
-    val b1: Maybe[Int] = getBalanceBank1
-    val b2: Maybe[Int] = getBalanceBank2
-    b1 + b2 // it will fail
+  val b1: Maybe[Int] = getBalanceBank1
+  val b2: Maybe[Int] = getBalanceBank2
+  val value: Maybe[Int] = summon[Combinable[Maybe[Int]]].combine(b1, b2)

  // EDGE OF THE WORLD
  println(balance)
//   println(balance)

Step 3: Syntax for Combinable

Let’s see how we can create an extension method. Previously, in Scala 2, you would declare something like:

implicit class CircleDecorator(c: Circle) extends AnyVal {
  def circumference: Double = c.radius * math.Pi * 2
}

Now we use the extension keyword. Notice that the compiler will transpile to something similar to what is written in the last example. However, you as a user are abstracted away from this.

extension [A: Combinable](aMaybe: Maybe[A])
    def +(bMaybe: Maybe[A]): Maybe[A] = (aMaybe, bMaybe) match

Then we update our Program.scala

-  val value: Maybe[Int] = summon[Combinable[Maybe[Int]]].combine(b1, b2)
  val value: Maybe[Int] = b1 + b2

Now the syntax takes care of the summoning for us, using the extension method. Great! Let’s move on. There is still a lot to do.

Step 4: Create a type-class transformer (with Syntax)

This is the new type class we will introduce that will help us add transform traits to each value contained within a given type class.

trait Transformable[F[_]]:
    def map[A, B](fa: F[A], f: A => B): F[B]
// lets add an extension method
  extension [A](self: Maybe[A])
    def map[B](f: A => B): Maybe[B] = summon[Transformable[Maybe]].map(self, f)

And now we can map our values in our Program.scala

- val b1: Maybe[Int] = getBalanceBank1
+ val b1: Maybe[Int] = getBalanceBank1.map(_.balance)

Step 5: Create a better version of the previous type class.

We will add a new case class to our domain Statement, as String does not cut it anymore.

@main def hello: Unit =
   case class Account(id: String, balance: Int)
+  case class Statement(isRich: Boolean, accounts: Int)
...

We can now create a Statement rather than a balance in our Program.scala.

- val balance: Maybe[Int] = b1 + b2
+ def statement =
+   summon[Transformable[Maybe]].map2(b1, b2, (x: Int, y: Int) => Statement(x + y > 1000, 2))
  // EDGE OF THE WORLD
- println(balance)
+ println(statement)

Step 6: Create type class Liftable

Again, we create a new type class. Say we have another stream of data called moneyInPocket, and we want to add this money to our Statement calculation.

For Scala 3:

 trait Liftable[F[_]]:
    def pure[A](a: A): F[A]
// code abreviated
    ...
extension [A](self: A)
    def pure[F[_]](using sv: Liftable[F]): F[A] = sv.pure(self)

For Scala 2 we would have:

 implicit class LiftableSyntax[A](self: A) {
    def pure[F[_]](implicit ev: Liftable[F]): F[A] = ev.pure(self)
  }

The difference in the way one expresses an extension method is clearer in Scala 3 than in Scala 2. When reading the Scala 2 code, one needs to intuit that the programmer meant it as an extension method when they wrote implicit class. But one could easily mistake class for var or def and especially so as a newcomer. Scala 3 corrects this by naming extension so you do not make a mistake when trying to use it.

Now, when updating our Program.scala, we have the ability to lift any value into our program, as such:

  def getBalanceBank1: Maybe[Account] = Yes(Account("a1", 100))
  def getBalanceBank2: Maybe[Int] = Yes(80)
+ val moneyInPocket: Int = 20

  val b1: Maybe[Int] = getBalanceBank1.map(_.balance)
  val b2: Maybe[Int] = getBalanceBank2
  val balance: Maybe[Int] = b1 + b2
  ...
+ val p: Maybe[Int] = moneyInPocket.pure
+ val balance: Maybe[Int] = b1 + b2 + p

Moving on.

Step 7: Create a Type class Flattener

If you haven’t figured out by now, we are almost done with every monad tutorial out there. Let’s create this mystical Flatterner trait:

  trait Flattener[F[_]]:
    def flatMap[A, B](fa: F[A], f: A => F[B]): F[B]

On why the type signature of the def flatMap method looks like this, we will take it as is. But we now have the possibility to effect-fully access the value contained, regardless of what the word effect means. It’s worth mentioning that there is also an intuition of sequentiality given by this trait.

For our working example, we can update Program.scala as such:

  case class Account(id: String, balance: Int)
  case class Statement(isRich: Boolean, accounts: Int)

- def getBalanceBank1: Maybe[Account] = Yes(Account("a1", 100))
+ def getBank1Credentials: Maybe[String] = Yes("MyUser_MyPassword")
+ def getBalanceBank1(credentials: String): Maybe[Account] = Yes(Account("a1", 100))
  def getBalanceBank2: Maybe[Int] = Yes(80)
  val moneyInPocket: Int = 20

- val b1: Maybe[Int] = getBalanceBank1.map(_.balance)
+ val b1: Maybe[Int] = getBank1Credentials.flatMap(cred => getBalanceBank1(cred).map(_.balance))
  val b2: Maybe[Int] = getBalanceBank2
  val p: Maybe[Int] = moneyInPocket.pure
  val balance: Maybe[Int] = b1 + b2 + p

Step 8: Use monadic Structure

Yes, the clickbait of the M word. This means that we can now use for-comprehensions and sequentially compose our data structures. In our Program.scala, it would look as such:


- val b1: Maybe[Int] = getBank1Credentials.flatMap(cred => getBalanceBank1(cred).map(_.balance))
- val b2: Maybe[Int] = getBalanceBank2
- val p: Maybe[Int] = moneyInPocket.pure
- val balance: Maybe[Int] = b1 + b2 + p

+  val balance =
+    for
+      c <- getBank1Credentials
+      b1 <- getBalanceBank1(c).map(_.balance)
+      b2 <- getBalanceBank2
+      p <- moneyInPocket.pure
+    yield b1 + b2 + p

The enhancements can proceed, as we can continue to introduce type classes to augment and describe traits in our programs. We will leave it here for now, but if there is a need for more detail, please review the repository. Make sure to read more on type classes, even more on type classesconsulting, and take a tour of functional type classes.

Simulating Scala 3 Contextual Abstractions concepts with Scala 2 implicit

Given Instances:

In Scala 3,

given intOrd: Ord[Int] with { ... }

maps to in Scala 2

implicit object intOrd extends Ord[Int] { ... }

Extension Methods

In Scala 3,

extension (c: Circle)
  def circumference: Double = c.radius * math.Pi * 2

maps to in Scala 2

implicit class CircleDecorator(c: Circle) extends AnyVal {
  def circumference: Double = c.radius * math.Pi * 2
}

Parameterized givens

In Scala 3, we have described it as,

given listOrd[T](using ord: Ord[T]): Ord[List[T]] with { ... }

Whereas in Scala 2, we need to do some Gymnastics:

class listOrd[T](implicit ord: Ord[T]) extends Ord[List[T]] { ... }
final implicit def listOrd[T](implicit ord: Ord[T]): listOrd[T] =
 new listOrd[T]

This is not an exhaustive mapping, so make sure to visit the official documentation.

Conclusion

Contextual abstractions are one of Scala’s most powerful features. Few languages have it, and its overhaul makes it more attractive and easier to learn. We went through the definition of contextual abstractions, its critiques in the Scala 2 implementation, the introduction of four fundamental changes that improve the language orthogonality of Scala 3, and type class code examples. I think it’s safe to conclude that, compared to Scala 2, it is easier to digest Contextual abstraction in Scala 3. It also provides deeper insights when debugging your Scala 2 implicits compiler problem.

Questions?

Get in touch with us to learn more about the subject and related solutions

Explore related posts