Search code examples
scalascala-cats

Making Implemetation More Generic With Scala and Cats


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))
}

Solution

  • 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.

    1. Interface. Especially 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]
    
    1. Creation. Since a 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]] 
    
    1. Implementation. Your intuition was right, we want to use a mutable reference to an immutable 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))
          }
        }
    }
    
    1. The constraints, the above code needs a constraint on 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]] = ...