I have this in-memory implementation of a simple Cache in Scala using cats effects.
Here is my trait:
trait Cache[F[_], K, V] {
def get(key: K): F[Option[V]]
def put(key: K, value: V): F[Cache[F, K, V]]
}
import cats.effect.kernel.Async
case class ImmutableMapCache[F[_]: Async, K, V](map: Map[K, V]) extends Cache[F, K, V] {
override def get(key: K): F[Option[V]] =
Async[F].blocking(map.get(key))
override def put(key: K, value: V): F[Cache[F, K, V]] =
Async[F].blocking(ImmutableMapCache(map.updated(key, value)))
}
object ImmutableMapCache {
def empty[F[_]: Async, K, V]: F[Cache[F, K, V]] =
Async[F].pure(ImmutableMapCache(Map.empty))
}
Is this a good enough implementation? I'm restricting my effect to Async. Can I make it even more generic to work with other effect types in my ImmutableMapCache?
What other pitfalls are there with my approach?
EDIT:
Is this a better implementation where I wrap the Map in a Cats Ref context?
import cats.effect.{Ref, Sync}
import cats.syntax.all._
class SimpleCache[F[_]: Sync, K, V] extends Cache[F, K, V] {
private val cache: Ref[F, Map[K, V]] = Ref.unsafe[F, Map[K, V]](Map.empty)
override def put(key: K, value: V): F[Unit] = cache.update(_.updated(key, value))
override def get(key: K): F[Option[V]] = cache.get.map(_.get(key))
}
First of all, the right answer is not to reinvent the wheel, and just use a library that already does all this, like mules.
However, for the sake of learning, let's take a look to some of the things you could improve.
put
def put(key: K, value: V): F[Cache[F, K, V]]
If putting a new value in the Cache
returns a new one, it means it is immutable, if it is immutable there is no need for effects, meaning it is just a simple Map
.
You want your put
to mutate something; but in a concurrent-safe way. So it could actually be used to share data between different Fibers
. Thus, put
should be defined like this:
def put(key: K, value: V): F[Unit]
Cache
is a mutable state, its creation must be an effect as well; like your original example but unlike your attempt with Ref
def empty[F[_], K, V]: F[Cache[F, K, V]]
Map
. Now, in order to make it concurrent safe, we need to either look the access to it, or use a CAS loop. cats-effect provides both options: AtomicCell
and Ref
respectively. In this case is better to just use a CAS loop so we go with Ref
object Cache {
def empty[F[_], K, V]: F[Cache[F, K, V]] =
Ref[F].of(Map.empty[K, V]).map { ref =>
new Cache[F[_], K, V] {
override def get(key: K): F[Option[V]] =
ref.get.map(map => map.get(key))
override def put(key: K, value: V): F[Unit] =
ref.update(map => map.updated(key, value))
}
}
}
F
to actually work. But actually, we don't need all the Async
power, but rather just Concurrent
, since all we need is to create a Ref
:)def empty[F[_] : Concurrent, K, V]: F[Cache[F, K, V]] = ...