Implicits and typeclasses in Scala

The article, to a greater extent, will be of interest to beginning rock climbers and is essentially a slightly revised lecture summary. It’s also worth noting that all code examples are written in Scala 2.

Link to original

This is our plan

Implicit conversions

So, let's start right away with an example! Let's say we have this code. The question is – will it compile?

val x: String = 123

Of course not! We will get an error because we are trying to assign a variable with a string type to a value with a numeric type, and therefore the compiler slaps us on the wrist – it says that we cannot do this:

[error] ...: type mismatch;
[error]  found   : Int(123)
[error]  required: String
[error]   val x: String = 123
[error]                   ^
[error] one error found
[error] (Compile / compileIncremental) Compilation failed

But Scala has a way to make such code compile. We need to resort to using the implicit conversion mechanism. Let's look at the following example:

implicit def intToString(x: Int): String = x.toString

val x: String = 123 // будет вызван intToString

Will this code compile? Yes! What's going on here? We have a function that is marked with a keyword implicitwhich may cause it to be called implicitly.

implicit def func(param: A): B = ???

Implicit conversions can have arbitrary names. But you may ask – it is called implicitly, why does it need a name? The name of the implicit function matters only in two situations:

implicit def intToString(x: Int): String = x.toString
val x: String = intToString(123)
  • if you need to determine what implicit transformations are available in a particular place in the program when we import

object my_implicits {
    implicit def intToString(x: Int): String = x.toString
}
import my_implicits.intToString
val x: String = 123

Thus, to define implicit functions

  • You need to use a keyword implicit

  • This is a function and must be declared inside a trait/class/object/method (the main thing is that it cannot be at the top level)

  • The argument list must only contain one parameter

// Если неявная функция будет принимать два или более аргументов,
// то она не будет вызываться неявно
implicit def func(argA: A, argB: B): C = ???

Implicit scopes and priorities

The compiler will only use those implicit conversions that are in scope. Therefore, to ensure the availability of implicit functions, you need to somehow put them in scope.

Let's look at the key points you should pay attention to

Local scope

Implicit functions can be defined in the current scope, for example, inside a method or object. Such functions will take precedence over implicit functions from other scopes.

object Example {  
  
  implicit def intToString(x: Int): String = x.toString  
  
  val x: String = 123  
}

It is worth noting that if you declare two implicit functions with the same signatures, then in this case (as expected) you will get a compilation error, since the compiler does not know which one to use for conversion.

object Example {  
  
  implicit def intToString1(x: Int): String = x.toString  
  implicit def intToString2(x: Int): String = x.toString  
  
  val x: String = 123  
}

// Получим ошибку
// [error] Note that implicit conversions are not applicable because they are ambiguous:
// [error]  both method intToString1 in object Example of type (x: Int): String
// [error]  and method intToString2 in object Example of type (x: Int): String

Imports

Implicit functions imported into the current scope are also available for use. This allows you to control the availability of implicit conversions and parameters at the level of individual files or blocks of code.

object ExternalImplicits {  
    implicit def intToString(x: Int): String = x.toString  
}  

object Example {  
  
  import ExternalImplicits.intToString  
  // ИЛИ импортируем все
  import ExternalImplicits._  
  
  val x: String = 123  
}

Companion objects

The compiler will also look for implicit functions in the companion object of the type for which the conversion is taking place, or in the type of the function parameter. This means that if you define an implicit function in a class companion object Currencythen the implicit function will be available wherever available Currency.

trait Currency  
case class Dollar(amount: Double) extends Currency  
case class Euro(amount: Double) extends Currency  
  
object Currency {  
    implicit def euroToDollar(euro: Euro): Dollar = Dollar(euro.amount * 1.13)  
}

object Example extends App {  
  
    val dollar: Dollar = Euro(100) // euroToDollar  
}

If two implicit functions with the same signature are defined – one in a companion object and the other in the current scope, then the function from the current scope will be used.

trait Currency  
case class Dollar(amount: Double) extends Currency  
case class Euro(amount: Double) extends Currency  
  
object Currency {  
    implicit def euroToDollar(euro: Euro): Dollar = Dollar(euro.amount * 1.13)
}

object Example extends App {  

    implicit def euroToDollar(euro: Euro): Dollar = Dollar(euro.amount)

    val dollar: Dollar = Euro(100) // result: 100
}

Warning!

With great power comes great responsibility! Unconscious use of implicit conversions can lead to writing code that is difficult to understand (especially when the code base becomes large). They should be used consciously only where it actually improves the code and does not create additional confusion!

You may have a question – why then did we study implicit conversions if their use is an anti-pattern?

The answer is – this is a language mechanism that is worth knowing about and understanding how it works. Since the implicits themselves are used not only for transformations, but also participate in other mechanisms of language.

Implicit parameters

def func(implicit x: Int): Unit = ???

Implicit parameters are a powerful feature that allows functions to automatically obtain values ​​for their parameters from the current scope without explicitly passing arguments when calling the function.

Let's look at a simple example:

def multiply(x: Int)(implicit y: Int) = x * y

implicit val z: Int = 10 // должна быть неявной

multiply(3) // result: 30 
multiply(4) // result: 40

In this case, the method multiply argument y is conveyed implicitly. It is worth noting that the variable being passed must also be marked as implicit.

If there are two implicitly defined variables with the same type in the scope, then (again expectedly) we will get a compilation error at the output, since the compiler does not understand which variable to use:

implicit val z: Int = 10  
implicit val y: Int = 42  
  
multiply(3)

// [error]  ....scala:119:11: ambiguous implicit values:
// [error]  both value z in object ExampleImplicitParameters of type Int
// [error]  and value y in object ExampleImplicitParameters of type Int
// [error]  match expected type Int
// [error]   multiply(3)

Correct and incorrect examples of declaring functions with implicit parameters:

  • def func(implicit x: Int) – argument x implicit

  • def func(implicit x: Int, y: Int) – arguments x And y implicit

  • def func(x: Int, implicit y: Int)compilation error!

  • def func(x: Int)(implicit y: Int) – argument y implicit

  • def func(implicit x: Int)(y: Int)compilation error!

  • def func(implicit x: Int)(implicit y: Int)compilation error!

That is, the group of implicit parameters should always be the last one.

Let's look at an example of using implicit parameters that is closer to real life. Let's say we have an application that has a logger. The application has some context, for example it contains some request id, and we need to log information from this context. When passing this parameter to the logger, explicitly in the application code when logging, you are forced to write logger.log(...)(requestContext)

case class RequestContext(requestId: String)  

class Logger {
  def log(message: String)(ctx: RequestContext): Unit = {  
      println(s"[${ctx.requestId}] $message")  
  }
}

object SomeApplication extends App {
  val logger = new Logger()

  def handle(requestContext: RequestContext) = {
    logger.log("Starting process")(requestContext)
    // some action ...
    logger.log("Continue process...")(requestContext)
    // some action ...
    logger.log("End process")(requestContext)
  }
}

Whereas by making the request parameter implicit, we can get rid of the explicit passing of this parameter, thereby simplifying and reducing the amount of code in the business logic of our application.

case class RequestContext(requestId: String)  

class Logger {
  def log(message: String)(implicit ctx: RequestContext): Unit = {  
      println(s"[${ctx.requestId}] $message")  
  }
}

object SomeApplication extends App {
  val logger = new Logger()

  def handle(implicit requestContext: RequestContext) = {
    logger.log("Starting process")
    // some action ...
    logger.log("Continue process...")
    // some action ...
    logger.log("End process")
  }
}

Implicit classes

Scala has the ability to make classes implicit; it will look like this.

implicit class ImplicitClass(val field: Int) extends AnyVal {
  def method: Unit = ???
}

What are they needed for?

Let's find out! Let's start with the question – what is the difference between our code and the libraries of other developers? The fundamental difference is that we can change or expand our code if we wish, but libraries often have to be accepted as they are.

To make this problem easier to solve, there are a number of approaches in programming languages. In OOP languages, for example, you can use the structural pattern adapter. For example, we want to extend a type (or class) Int methods for checking evenness and oddness. To do this, create a wrapper class IntAdapterin which we implement the methods we need.

class IntAdapter(val i: Int) {
  def isEven: Boolean = i % 2 == 0
  def isOdd: Boolean = !isEven
}

// Создание экземпляра адаптера и использование его методов
new IntAdapter(42).isEven  // true
new IntAdapter(42).isOdd   // false

In Scala, we can use implicit classes for this purpose. To do this, let's rewrite our example as follows.

implicit class RichInt(val i: Int) extends AnyVal {
  def isEven: Boolean = i % 2 == 0
  def isOdd: Boolean = !isEven
}

10.isEven  // true
10.isOdd   // false

The difference will be that methods for checking for even and odd parity can now be called as if they belonged to the type Int. This allows you to write more concise and expressive code.

Note: inherit from AnyVal in Scala it is used to create value classeswhich are an optimization mechanism to avoid memory allocation for wrapper objects.

You probably noticed that the example with the implicit class is suspiciously similar to the example with the adapter written above. The point is that if we get rid of syntactic sugar (in IntelliJ IDEA you can do Desugar Scala Code), we will see that in the desugared code, an explicit wrapper is wrapped in a class and its methods are called.

// Обессахаренный код
org.example.app.RichInt(10).isEven  // true
org.example.app.RichInt(10).isOdd   // false

That is, in fact, under the hood, the same adapter pattern is used, flavored with an implicit mechanism.

Type classes

A typeclass is a pattern used in functional programming to provide Ad-hoc polymorphismknown as method overloading. This pattern allows us to write code in which we operate on interfaces and abstractions and still use the correct type-based implementation of these abstractions.

Polymorphism through inheritance

Let's start by looking at an abstract example in which there are classes Circle And Rectangle. We need to enrich them with a method to calculate area.

trait Area {
  def area: Double
}

class Circle(radius: Double) extends Area {
  override def area: Double = math.Pi * math.pow(radius, 2)
}

class Rectangle(width: Double, length: Double) extends Area {
  override def area: Double = width * length
}

// Обобщенная функция
def areaOf(area: Area): Double = area.area

areaOf(new Circle(10))
areaOf(new Rectangle(5, 5))

When using polymorphism through inheritance we create an interface Area with method area and inherit classes from it Circle And Rectangle, in which we implement this method. This allows us to create a generic function areaOfcapable of working with any type that inherits from Area.

This approach is, to a greater extent, inherent in OOP, when fields and methods are in the class definition. That is, entities representing data concentrated nearby with entities responsible for behavior.

Polymorphism through typeclasses

Typeclasses offer an approach where the entities representing the data separated from entities responsible for behavior.

In the following example the interface Area is a typeclass. It is parameterized and its method takes an argument as input – the very data that will need to be operated in interface implementations.

// сущности, представляющие данные
case class Circle(radius: Double)
case class Rectangle(width: Double, length: Double)

// тайпкласс
trait Area[A] {
  def area(a: A): Double
}

// сущности, отвечающие за реализацию
object CircleArea extends Area[Circle] {
  override def area(circle: Circle): Double = math.Pi * math.pow(circle.radius, 2)
}

object RectangleArea extends Area[Rectangle] {
  override def area(rectangle: Rectangle): Double = rectangle.width * rectangle.length
}

// Обобщенная функция
def areaOf[A](shape: A, area: Area[A]): Double = area.area(shape)

areaOf(Circle(11), CircleArea)
areaOf(Rectangle(12, 15), RectangleArea)

We can reduce the amount of code if we create implicit typeclass instances Area for types Circle And Rectangleand also if we forward these instances to the function areaOf implicitly.

implicit val circleArea: Area[Circle] = new Area[Circle] {
  override def area(circle: Circle): Double = math.Pi * math.pow(circle.radius, 2)
}

implicit val rectangleArea: Area[Rectangle] = new Area[Rectangle] {
  override def area(rectangle: Rectangle): Double = rectangle.width * rectangle.length
}

// Обобщенная функция
def areaOf[A](figure: A)(implicit area: Area[A]): Double = area.area(figure)

areaOf(Circle(42))
areaOf(Rectangle(12, 15))

We can go even further. By replacing the function areaOf to the implicit class, we can add syntax for a typeclass, which will allow you to call the method area as if he belongs to the types Circle And Rectangle.

// Синтаксис
implicit class AreaSyntax[A](val figure: A) extends AnyVal {
  def area(implicit area: Area[A]): Double = area.area(figure)
}

Circle(42).area
Rectangle(12, 15).area

From these examples it is clear that typeclasses, in fact, can be implemented in OOP programming languages, but in Scala they look more elegant and expressive due to implicits.

Anatomy of type classes

So what are typeclasses ultimately made of? They consist of three mandatory components:

  • trait (type class itself)

  • typeclass methods

  • trait instances for specific types

  • syntax based on implicit class (optional)

Let's take a closer look at these components. Here is an example of a trait:

trait TypeClass[A] {
  def method(value: A): Unit
}

This is actually the typeclass itself which has a certain method. It is important to note that this trait is parameterized by some type A.

Next, we create instances of this typeclass, for example for the type Int. Essentially, here we are writing an implementation of the class and creating an instance of it, and its instance is created in the form of an implicit variable. Typically, instances are placed inside an object, which is called by the name of the typeclass and the prefix Instances.

object TypeClassInstances {
  implicit val intInstance: TypeClass[Int] = new TypeClass[Int] {
    def method(value: Int): Unit = ???
  }
}

Well, an optional component of typeclasses is an implicit class, which allows us to create some syntax for our typeclass.

object TypeClassSyntax {
  implicit class TypeClassOps[A](private val value: A) extends AnyVal {
    def method(implicit ev: TypeClass[A]): Unit = ev.method(value)
  }
}

Typeclass instance delivery methods

We can pass a typeclass instance through function arguments (explicitly or implicitly).

object SomeApp {
  def someMethod[A](arg: A)(implicit t: TypeClass[A]): Unit = {
    t.method(arg)
  }
}

Typeclasses can also be passed through context bounds type parameters.

object SomeApp {
  def someMethod[A: TypeClass]: Unit = {
    TypeClass[A].method
  }
}

But to do this, you must first create a typeclass companion object with a method applywhich can get an implicit instance of a typeclass.

trait TypeClass[A] {
  def method(value: A): Unit
}

object TypeClass {
  def apply[A](implicit ev: TypeClass[A]): TypeClass[A] = ev
}

To use typeclass syntax, you must import the syntax.

object SomeApp {
  import TypeClassSyntax._
  
  def someMethod[A: TypeClass](arg: A): Unit = {
    arg.method
  }  
}

Simple type classes

Let's look at a couple of real-life typeclasses.

Show

Show is an alternative for Java method toString. It is defined by a single function show.

You might be wondering what this typeclass is for, given that toString already serves the same purpose. Moreover, case classes have good implementations of the method toString. The problem is that toString defined at the level Any (or java Object) and therefore can be called for anything, which is not always correct:

(new {}).toString
// result: "example.ExampleApp$$anon$5@5b464ce8"

That is, typeclass Show will allow us to define conversions to strings only for the types we need. Let's look at an example of implementing a typeclass:

// Тайпкласс
trait Show[A] {  
  def show(value: A): String  
}  
  
// Объект-компаньон
object Show {  
  def apply[A](implicit ev: Show[A]): Show[A] = ev  
}  
  
// Инстансы тайпкласса для Int и String
object ShowInstances {  
  implicit val showInt: Show[Int] = new Show[Int] {
    def show(value: Int): String = value.toString
  }

  implicit val showString: Show[String] = new Show[String] {
    def show(value: String): String = value
  }
}  
  
// Синтаксис
object ShowSyntax {  
  implicit class ShowOps[A](private val value: A) extends AnyVal {  
    def show(implicit ev: Show[A]): Unit = ev.show(value)  
  }  
}  

Usage example Show with primitive types.

import ShowInstances._
import ShowSyntax._  
  
val meaningOfLife = 42  
  
Show[Int].show(meaningOfLife)   // result: "42"  
// or
meaningOfLife.show   // result: "42"  

Usage example Show with custom types.

import ShowInstances._
import ShowSyntax._  

case class User(name: String, age: Int)

object User {
  
  implicit val showUser: Show[User] = new Show[User] {
    def show(user: User): String = s"User(name = ${user.name}, age = ${user.age})"
  }
}
  
val user = User("Mark", 25)
user.show
// result: "User(name = Mark, age = 25)"

Eq

Eq is an alternative to the standard method java equals. Problem with Java equals is that we can compare two completely unrelated types and will not receive an error from the compiler (at most we will receive a warning), which can lead to funny bugs.

"Hello" == 42
// res1: Boolean = false

By introducing this typeclass, we will cut off the ability to compare values ​​with different types at the compilation level.

Let's look at an example of implementing a typeclass:

// Тайпкласс
trait Eq[A] {
  def eqv(x: A, y: A): Boolean
}

// Объект-компаньон
object Eq {
  def apply[A](implicit ev: Eq[A]): Eq[A] = ev
}
  
// Инстансы тайпкласса для Int и String
object EqInstances {
  implicit val eqInt: Eq[Int] = new Eq[Int] {
    def eqv(x: Int, y: Int): Boolean = x == y
  }

  implicit val eqString: Eq[String] = new Eq[String] {
    def eqv(x: String, y: String): Boolean = x == y
  }
}

// Синтаксис
object EqSyntax {
  implicit class EqOps[A](private val x: A) extends AnyVal {
    def eqv(y: A)(implicit ev: Eq[A]): Boolean = ev.eqv(x, y)
    def ===(y: A)(implicit ev: Eq[A]): Boolean = ev.eqv(x, y)
    def =!=(y: A)(implicit ev: Eq[A]): Boolean = !ev.eqv(x, y)
  }
}

Usage example Eq with primitive types.

import EqInstances._  
import EqSyntax._  

Eq[Int].eqv(2 + 2, 4)   // result: true  
  
"Hello" === "world"     // result: false  
"Hello" =!= "world"     // result: true  

An attempt to compare values ​​of different types will be severely suppressed by the compiler.

"Hello" === 42  
/*  
  [error]  fff.scala:202:15: type mismatch;  
  [error]  found   : Int(42)  
  [error]  required: String  
  [error]   "Hello" === 42  
  [error]               ^  
  [error] one error found  
  [error] (Compile / compileIncremental) Compilation failed */

Usage example Eq with custom types.

case class User(name: String, age: Int)  
  
object User {
  implicit val eqUser: Eq[User] = new Eq[User] {  
    def eqv(x: User, y: User): Boolean = x.name === y.name && x.age === y.age
  }
}

val mark = User("Mark", 25)
val joe = User("Joe", 33)

mark === joe  // result: false

Conclusion

Mastering implicits and typeclasses is an important step towards becoming a Scala developer. However, it is important to use them wisely. When used correctly, implicits and typeclasses help you write concise, expressive, and easily extensible code, while highlighting the power of Scala.

Some links

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *