Scala: Tagless Final

Posted 楠爸自习室

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Scala: Tagless Final相关的知识,希望对你有一定的参考价值。


Tagless Final is a coding pattern in Scala.

You may ask why it was called Tagless Final, it's a long story but won't block us to use it, so let's answer the question in the future(or you can read Introduction to Tagless Final[1] first).

In this blog, we will focus on how to use it.

Requirement

Let's start from a simple requirement.

Say we have an api which can get a user by id, and to make it easy to debug, we want to log the id every time.

Java implementation

If we are Java developer, we may implement it like this

class User {
  String name;
  Int age;
  String id;

  User(String name, Int age, String id) {
    this.name = name;
    this.age = age;
    this.id = id;
  }
}

interface Logger {
  public void info(message: String);
}

class ConsoleLogger extends Logger {
  public void info(message: String) {
    System.out.println(message);
  }
}

interface UserApi {
  public User getUser(id: String);
}

class InMemoryUserApi implement UserApi {
  Logger logger;
  Map<String, User> cache;

  InMemoryUserApi(logger: Logger, cache: Map<String, User>) {
    this.logger = logger;
    this.cache = cache
  }

  public User getUser(id: String) {
    logger.info("Getting user by " + id);
    return cache.get(id);
  }
}

Scala implementation

It can be translated to Scala directly

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

trait Logger {
  def info(message: String): Unit
}

class ConsoleLogger extends Logger {
  def info(message: String): Unit = {
    println(message)
  }
}

trait UserApi {
  def getUser(id: String): User
}

class InMemoryUserApi(logger: Logger, cache: Map[StringUser]extends UserApi {
  def getUser(id: String): User = {
    logger.info(s"Getting user by ${id}")
    cache.get(id)
  }
}

Pure implementation

You may notice the function getUser and info have side effect(console output and exception). To make them pure, we need to involve some higher-kind type to express the side effect, such as Option, Either or IO etc.

We use F[_] to stand for them here and call it as effect in the following part.

Then the code become

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


trait Logger[F[_]] {
  def info(message: String): F[Unit]
}

class ConsoleLogger[F[_]] extends Logger[F{
  def info(message: String): F[Unit] = {
    // println(message)
  }
}

trait UserApi[F[_]] {
  def getUser(id: String): F[User]
}

class InMemoryUserApi[F[_]](logger: Logger[F], cache: Map[StringUser]extends UserApi[F{
  def getUser(id: String): F[User] = {
    // logger.info("Getting user by ${id}")
    // cache.get(id)
  }
}

There are two questions here

  1. Why not put F[_] on function?

    If we put F[_] on function, we can't involve any type class of F[_] in the child class which mean we can't utilize Monad, Functor or Sync to implement the logic

    def info[F[_]](message: String "F[_]"): F[Unit]

    is different from

    def info[F[_]: Monad](message: String): F[Unit]

    // ===

    def info[F[_]](message: String)(implicit MMonand[F]): F[Unit]

    And it's hard to maintain the code if the effects are different in one class.

  2. How to re-implement the logic by F[_]

    We need to make all the function pure, so we need the type class of F[_] which can be injected by context bound.

    We can use  Sync of F[_] to print log without side effect.

    class ConsoleLogger[F[_]:Syncextends Logger[F] {
      def info(message: String): F[Unit] = {
        Sync[F].delay(println(message));
      }
    }

    We can use  Monad of F[_] to chain expression.Sync is also a Monad, we can still use Sync here.

    class InMemoryUserApi[F[_]:Sync](logger: Logger[F], cache: Map[StringUser]) extends UserApi[F] {
      def getUser(id: String): F[User] = for {
        _ <- Sync[F].delay(logger.info("Getting user by ${id}"));
        user <- Sync[F].delay(cache.get(id))
      } yield user
    }

Now we have an implementation using Tagless Final pattern, but the F[_] is still undetermined, how should we run it in main?

def main()= {
  val logger = new ConsoleLogger[IO]
  val userApi = new InMemoryUserApi[IO](logger, Map("1" -> User("James"21"1"), "2" -> User("Tom""38""2")))

  userApi.get("1").unsafeRunSync // "Jame"
  userApi.get("3").unsafeRunSync // Error
}

We choose IO to be F[_] in main, because IO already implemented the type class Sync, which is the minimum requirement of implementation.

There may be more questions here

  1. Why not use IO directly in implementation?

    If we use IO directly, we can not use other effect in the future, for example Task or ZIO.

    But to be honest, it's unlikely to happen in real system.

  2. Why should we only involve the minimum requirement of F[_]?

    If we use Sync everywhere for example, we can not prevent some team member from wrapping all the code in one Sync.delay, which is valid for compiler but a very bad code.

    We'd better just inject the dependencies required by implementation.

If your project complexity is low and team member don't have enough experience, I think it's ok to use an effect directly in implementation which is happening in my project.

Summary

Ok, let's give a simple definition of Tagless Final to help us understand it(not accurate but easier to understand)

Tagless Final is just OO + Effect

We can use normal OO technique to compose our code, but remember to add effect to Interface/Class, and inject the required type class by context bound, then utilize them to make the function pure.

参考资料

[1]

Introduction to Tagless Final: https://serokell.io/blog/tagless-final


关注【楠爸自习室】

以上是关于Scala: Tagless Final的主要内容,如果未能解决你的问题,请参考以下文章

linux打开终端如何启动scala,如何在终端下运行Scala代码片段?

Scala中的final类和sealed类有啥区别?

《快学Scala》第八章——继承

Scala之继承

Scala 面向对象编程之继承

7scala面向对象-继承