Understanding Implicit Scala

image

Recently, I have had several conversations with friends from the Java world about their experience using Scala. Most used Scala as improved Java and, in the end, were disappointed. The main criticism was directed at the fact that Scala is too powerful a language with a high level of freedom, where the same thing can be implemented in various ways. Well, and the cherry on the cake of dissatisfaction are, of course, implicit'y. I agree that implicit is one of the most controversial features of the language, especially for beginners. The very name "implicit", as it hints. In inexperienced hands, implicit ones can cause poor application design and many errors. I think everyone working with Scala at least once encountered errors resolving ipmlisitnyh dependencies and the first thoughts were what to do? where to look? how to solve a problem? As a result, I had to google or even read the documentation for the library, if any, of course. Usually the solution is found by importing the necessary dependencies and the problem is forgotten until the next time.

In this post, I would like to talk about some common practices of using implicits and help make them more “explicit” and understandable. The most common use cases:

  • Implicit parameters
  • Implicit conversions
  • Implicit classes ("Implicit classes -" Pimp My Library "pattern)
  • Type classes

The network has many articles, documentation and reports on this topic. However, I would like to dwell on their practical application on the example of creating a Scala-friendly API for a wonderful Java libraryTypesafeLightbend Config . First you need to answer the question, but what, in fact, is wrong with the native API? Let's take a look at the example from the documentation.

import com.typesafe.config.ConfigFactory
val conf = ConfigFactory.load();
val foo = config.getString("simple-lib.foo")
val bar = config.getInt("simple-lib.bar")

I see at least two problems here:

  1. Error processing. For example, if a method getIntcannot return a value of the desired type, an exception will be thrown. And we want to write "clean" code, without exception.
  2. Extensibility. This API supports some Java types, but what if we want to extend type support?

Let's start with the second problem. The standard Java solution is inheritance. We can extend the functionality of the base class by adding new methods. This is usually not a problem if you own the code, but what if it is a third-party library? The “naive” solution path in Scala will be through the use of implicit classes or the “Pimp My Library” pattern.

implicit class RichConfig(val config: Config) extends AnyVal {
  def getLocalDate(path: String): LocalDate = LocalDate.parse(config.getString(path), DateTimeFormatter.ISO_DATE)
}

Now we can use the method getLocalDateas if it were defined in the source class. Not bad. But we solved the problem only locally and we must support all the new functionality in one RichConfigclass or potentially have the "Ambiguous implicit values" error if the same methods are defined in different implicit classes.

Is there any way to improve this? Here, let's recall that usually in Java, inheritance is used to implement polymorphism. In fact, polymorphism can be of different types:

  1. Ad hoc polymorphism.
  2. Parametric polymorphism.
  3. Subtype Polymorphism

Inheritance is used to implement subtype polymorphism. We are interested in ad hoc polymorphism. It means that we will use a different implementation depending on the type of parameter. In Java, this is implemented using method overloading. In Scala, it can be additionally implemented using type classes. This concept came from Haskel, where it is built into the language, and in Scala it is a pattern that requires implicit ones to implement. Briefly Foo[T], a type class is a contract, for example, a trait parameterized by a type Tthat is used to resolve implicit dependencies and the desired contract implementation is selected by type. It sounds confusing, but it's really simple.

Let's look at an example. For our case, we define a contract for reading the value from the config:

trait Reader[A] {
  def read(config: Config, path: String): Either[Throwable, A]
}

As we can see, the trait is Readerparameterized by type A. To solve the first problem we return Either. No more exceptions. To simplify the code, we can write a type alias.

trait Reader[A] {
  def read(config: Config, path: String): Reader.Result[A]
}
object Reader {
  type Result[A] = Either[Throwable, A]
  def apply[A](read: (Config, String) => A): Reader[A] = new Reader[A] {
    def read[A](config: Config, path: String): Result[A] = Try(read(config, path)).toEither
  }
  implicit val intReader = Reader[Int]((config: Config, path: String) => config.getInt(path))
  implicit val stringReader = Reader[String]((config: Config, path: String) => config.getString(path))
  implicit val localDateReader = Reader[LocalDate]((config: Config, path: String) => LocalDate.parse(config.getString(path), DateTimeFormatter.ISO_DATE);)
}

We have identified taip Reader class and added a few implementations for types Int, String, LocalDate. Now you need to learn how to Configwork with our type class. And here the “Pimp My Library” pattern and implicit arguments are already useful:

implicit class ConfigSyntax(config: Config) extends AnyVal {
  def as[A](path: String)(implicit reader: Reader[A]): Reader.Result[A] = reader.read(config, path)
}

We can rewrite more briefly with context bounds:

implicit class ConfigSyntax(config: Config) extends AnyVal {
  def as[A : Reader](path: String): Reader.Result[A] = implicitly[Reader[A]].read(config, path)
}

And now, a usage example:

val foo = config.as[String]("simple-lib.foo")
val bar = config.as[Int]("simple-lib.bar")

Type classes are a very powerful mechanism that allows you to write easily extensible code. If support for new types is required, then you can simply write the implementation of the desired type of class and put it in context. Also, using priority in resolving implicit dependencies, you can override the standard implementation. For example, you can define another LocalDatereader option :

implicit val localDateReader2 = Reader[LocalDate]((config: Config, path: String) =>
  Instant
    .ofEpochMilli(config.getLong(path))
    .atZone(ZoneId.systemDefault())
    .toLocalDate()
)

As we can see, implicit, when used correctly, allow you to write clean and extensible code. They allow you to expand the functionality of third-party libraries, without changing the source code. Allow you to write generalized code and use ad hoc polymorphism using type classes. There is no need to worry about a complex hierarchy of classes, you can simply divide the functionality into parts and implement them separately. The principle of divide and rule in action.

Github project with examples.

Also popular now: