📜 ⬆️ ⬇️

Understand Implicits in Scala

image

Recently, I have had several conversations with friends from the Java world about their experiences using Scala. Most used Scala, as improved Java and, as a result, were disappointed. The main criticism was directed but the fact that Scala is too powerful language with a high level of freedom, where the same can be implemented in various ways. Well, the cherry on the cake of discontent are, of course, implicits. I agree that implicits are one of the most controversial features of the language, especially for beginners. The name itself is "implicit", as if hinting. In inexperienced hands, implicits can cause poor application design and a lot of errors. I think everyone working with Scala has at least once encountered errors in resolving first-time dependencies and the first thoughts were what to do? where to look? how to solve a problem? As a result, you had to google or even read the library documentation, if there is one, of course. Usually the solution is to import 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 implications and to help them make them more “explicit” and understandable. The most common options for their use:


There are many articles, documentation and reports on this topic. However, I would like to dwell on their practical application on the example of creating the Scala-friendly API for the wonderful Java library. Typesafe Lightbend Config . First you need to answer the question, but what, in fact, is wrong with the native API? Let's take a look at an 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 the getInt method getInt to return the value of the desired type, an exception will be thrown. And we want to write "clean" code, with no exceptions.
  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 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 getLocalDate method as if it were defined in the source class. Not bad. But we solved the problem only locally and we must support all new functionality in the same RichConfig class or potentially have an “Ambiguous implicit values” error if the same methods are defined in different implicit classes.

Is there any way to improve this? Here, let's remember 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 polymorphism of subtypes. We are also interested in ad hoc polymorphism. It means that we will use a different implementation depending on the type of the parameter. In Java, this is implemented using method overloading. In Scala, it can be optionally implemented using class classes. This concept came from Haskel, where it is embedded in the language, and in Scala it is a pattern that requires implicit'ov for implementation. In brief, the type class is a contract, for example, Foo[T] trait, parameterized by type T , which is used in resolving implicit dependencies, and the desired implementation of the contract is chosen by type. It sounds confusing, but in fact it is simple.

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

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

As we can see, the treater Reader parameterized with 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 defined the type reader Reader and added several implementations for the types Int , String , LocalDate . Now we need to teach Config to work with our taip class. And here the “Pimp My Library” pattern and implicit arguments come in handy:

 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 it more briefly using 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, an example of use:

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

Typpe classes are a very powerful mechanism that allows you to write easily extensible code. If support of new types is required, then you can simply write an implementation of the necessary type of class and place it in context. Also, using priority in resolving implicit dependencies, you can override the standard implementation. For example, you can define another version of the LocalDate reader:

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

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

Github project with examples.

Source: https://habr.com/ru/post/354028/


All Articles