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 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[String, User]) 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[String, User]) extends UserApi[F] {
def getUser(id: String): F[User] = {
// logger.info("Getting user by ${id}")
// cache.get(id)
}
}
There are two questions here
-
Why not put
F[_]
on function?If we put
F[_]
on function, we can’t involve any type class ofF[_]
in the child class which mean we can’t utilize Monad, Functor or Sync to implement the logicdef info[F[_]](message: String): F[Unit]
is different from
def info[F[_]: Monad](message: String): F[Unit] // === def info[F[_]](message: String)(implicit M: Monand[F]): F[Unit]
And it’s hard to maintain the code if the effects are different in one class.
-
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
ofF[_]
to print log without side effect.class ConsoleLogger[F[_]:Sync] extends Logger[F] { def info(message: String): F[Unit] = { Sync[F].delay(println(message)); } }
We can use
Monad
ofF[_]
to chain expression.Sync
is also aMonad
, we can still useSync
here.class InMemoryUserApi[F[_]:Sync](logger: Logger[F], cache: Map[String, User]) 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
-
Why not use
IO
directly in implementation?If we use
IO
directly, we can not use other effect in the future, for exampleTask
orZIO
.But to be honest, it’s unlikely to happen in real system.
-
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 oneSync.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.
Comments