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:
- Given Instances : New way of defining “canonical” values of a type.
- Using Clauses : This means
implicit
arguments defined with theusing
keyword. - Given Imports : A special form of import wildcard selector is used to import given instances.
- 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 anextension
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.