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.
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 implicit
which 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 Currency
then 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)
– argumentx
implicitdef func(implicit x: Int, y: Int)
– argumentsx
Andy
implicitdef func(x: Int, implicit y: Int)
– compilation error!def func(x: Int)(implicit y: Int)
– argumenty
implicitdef 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 IntAdapter
in 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 areaOf
capable 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 Rectangle
and 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 apply
which 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.