Skip to content

Latest commit

 

History

History
424 lines (337 loc) · 14.5 KB

scala.md

File metadata and controls

424 lines (337 loc) · 14.5 KB

Scala

Use for-comprehension

Source

object CoolStickers {
  def make[F[_]: Sync]: F[CoolStickers[F]] =
    Ref.of[F, Vector[String]](Vector.empty).flatMap { ref =>
      Sync[F].delay(new scala.util.Random()).map { rnd =>
        new LiveCoolStickers[F](rnd, ref)
      }
    }
}

This code sample is just a constructor with an effect of F, but it is hard to tell at first sight because of nested flatMap and map. Instead, we can use for-comprehension.

object CoolStickers {
  def make[F[_]: Sync]: F[CoolStickers[F]] =
    for {
      ref <- Ref.of[F, Vector[String]](Vector.empty)
      rnd <- Sync[F].delay(new scala.util.Random())
    } yield new LiveCoolStickers[F](rnd, ref)
}

Much better now. I would also show you version for Scala3, where we can omit the curly braces.

object CoolStickers:
  def make[F[_]: Sync]: F[CoolStickers[F]] =
    for
      ref <- Ref.of[F, Vector[String]](Vector.empty)
      rnd <- Sync[F].delay(new scala.util.Random())
    yield new LiveCoolStickers[F](rnd, ref)

maxOption / lastOption

Source

private def lastId(updates: List[Update]): Option[Long] =
  updates match {
    case Nil      => None
    case nonEmpty => Some(nonEmpty.map(_.updateId).max)
  }

First, let's move the map from the nonEmpty match arm. This will not affect Nil arm, since it returns None.

private def lastId(updates: List[Update]): Option[Long] =
  updates.map(_.updateId) match {
    case Nil      => None
    case nonEmpty => Some(nonEmpty.max)
  }

But we can't do the same with max. That's because max raises an exception when there is no elements in collection. So, max should be used in case of non-empty collections, which is not our case. Our case is maxOption, which handles the case of empty collections not by throwing exception, but rather returing Option.

private def lastId(updates: List[Update]): Option[Long] =
  updates.map(_.updateId).maxOption

Another solution provided by @ivan-klass is to use lastOption (another method ending with -Option). It works because updates is pre-sorted, but is context-dependent change, so it is not a shame not to notice such optimization.

private def lastId(updates: List[Update]): Option[Long] =
  updates.lastOption.map(_.updateId)

It is, in fact, optimization, because it doesn't allocate new list. Whereas our previous solution first uses map, which returns new list, and secondly maxOption to return Option, the new solution returns Option right after lastOption, and map applies to that Option, and not to a list.

Stream methods

Source

private def pollCommands: Stream[F, BotCommand] = for {
    update <- api.pollUpdates(0)
    chatIdAndMessage <- Stream.emits(update.message.flatMap(a => a.text.map(a.chat.id -> _)).toSeq)
  } yield BotCommand.fromRawMessage(chatIdAndMessage._1, chatIdAndMessage._2)

Because of the usage of filterMap, map, and toSeq, we can figure out that update.message and message.text are of type of Option.

First of all, let's use fromOption constuctor for Stream, instead of Stream.emits(_.toSeq).

private def pollCommands: Stream[F, BotCommand] = for {
    update <- api.pollUpdates(0)
    chatIdAndMessage <- Stream.fromOption[F](update.message.flatMap(a => a.text.map(a.chat.id -> _)))
  } yield BotCommand.fromRawMessage(chatIdAndMessage._1, chatIdAndMessage._2)

Then, apply the same technique to update.message.

private def pollCommands: Stream[F, BotCommand] = for {
    update <- api.pollUpdates(0)
    message <- Stream.fromOption[F](update.message)
    chatIdAndMessage <- Stream.fromOption[F](message.text.map(message.chat.id -> _))
  } yield BotCommand.fromRawMessage(chatIdAndMessage._1, chatIdAndMessage._2)

Consider message.text.map(message.chat.id -> _). Here we have message.text which is Option[String], and message.chat.id which is Int. This code creates Option[(Int, String)].

Instead of .map(_ -> _) we can use tupleLeft method, provided for Option by cats.

private def pollCommands: Stream[F, BotCommand] = for {
    update <- api.pollUpdates(0)
    message <- Stream.fromOption[F](update.message)
    chatIdAndMessage <- Stream.fromOption[F](message.text tupleLeft message.chat.id)
  } yield BotCommand.fromRawMessage(chatIdAndMessage._1, chatIdAndMessage._2)

Next, let's use mapFilter, which would allow us to use chain of Stream operators, instead of for-comprehension.

private def pollCommands: Stream[F, BotCommand] =
  api
    .pollUpdates(0)
    .mapFilter(_.message)
    .mapFilter(m => m.text tupleLeft m.chat.id)
    .map(BotCommand.fromRawMessage(_, _))

Needless for-comprehension

Source

for
  updates <- bot.updates.compile.toList
  texts = updates.collect { case MessageReceived(_, m: TextMessage) => m.text }
  asr <- IO(assert(texts == messages.map(_._1)))
yield asr

Here we have for-comprehension, but it doesn't really compose IOs. Look at the numerated lines:

1  =>  updates <- bot.updates.compile.toList
2  =>  texts = updates.collect { case MessageReceived(_, m: TextMessage) => m.text }
3  =>  asr <- IO(assert(texts == messages.map(_._1)))

On the line 2 we do variable assigning, and on the line 3 singlehandedly wrap expression in IO. Do we really need for-comprehension here?

Here is the version without for-comprehension:

bot
  .updates.compile.toList
  .map(_.collect { case MessageReceived(_, m: TextMessage) => m.text })
  .map(texts => assert(texts == messages.map(_._1)))

Looks cleaner, but we can do even better. Consider first map call. We apply map in order to collect message texts from IO[List]. But we can use collect earlier directly on Stream.

bot
  .updates
  .collect { case MessageReceived(_, m: TextMessage) => m.text }
  .compile
  .toList
  .map(texts => assert(texts == message.map(_._1)))

Get rid of Applicative[F].unit

Source

def answerCallbacks[F[_]: Monad: TelegramClient]: Pipe[F, Update, Update] =
  _.evalTap {
    case CallbackButtonSelected(_, query) =>
      query.data match {
        case Some(cbd) =>
          for {
            _ <- query.message.traverse(_.chat.send(cbd))
            _ <- query.finish
          } yield ()
        case _ => Applicative[F].unit
      }
    case _ => Applicative[F].unit
  }

This functions instantiates Pipe, which is anonymous function on Stream. Therefore, we can operate with all the methods that Stream have.

There is two Applicative[F].unit calls, and it is probably can be replaced by other, more descriptive operations. Let's see if we can.

First Applicative[F].unit called when our input Stream emits not a CallbackButtonSelected event. In other words, we do nothing if event is not a callback. Can we just filter out non-callbacks from input stream? Yes, we can do it with collect method.

def answerCallbacks[F[_]: Monad: TelegramClient]: Pipe[F, Update, Update] =
  _
    .collect { case CallbackButtonSelected(_, query) => query }
    .evalTap { query =>
      query.data match {
        case Some(cbd) =>
          for {
            _ <- query.message.traverse(_.chat.send(cbd))
            _ <- query.finish
          } yield ()
        case _ => Applicative[F].unit
      }
    }

Let's explicitly put None as the second arm to Some(cbd).

def answerCallbacks[F[_]: Monad: TelegramClient]: Pipe[F, Update, Update] =
  _
    .collect { case CallbackButtonSelected(_, query) => query }
    .evalTap { query =>
      query.data match {
        case Some(cbd) =>
          for {
            _ <- query.message.traverse(_.chat.send(cbd))
            _ <- query.finish
          } yield ()
        case None => Applicative[F].unit
      }
    }

The second Applicative[F].unit is called when callback data is None.

We can't use the same trick with collect method to filter out Nones, as we have to keep both query and query.data variables. The easiest way to keep them both is to combine them into single Option and only then use collect. How we would do it, if query is scalar, and query.data is Option? Using the tupleLeft, as it was used in this trick.

def answerCallbacks[F[_]: Monad: TelegramClient]: Pipe[F, Update, Update] =
  _
    .collect { case CallbackButtonSelected(_, query) => query }
    .map { query => query.data tupleLeft query }
    .collect { case Some(query, cbd) => (query, cbd) }
    .evalTap { (query, cbd) =>
      for {
        _ <- query.message.traverse(_.chat.send(cbd))
        _ <- query.finish
      } yield ()
    }

Also, there is method that both transforms and filters Somes. It is called mapFilter.

def answerCallbacks[F[_]: Monad: TelegramClient]: Pipe[F, Update, Update] =
  _
    .collect { case CallbackButtonSelected(_, query) => query }
    .mapFilter { query => query.data tupleLeft query }
    .evalTap { (query, cbd) =>
      for {
        _ <- query.message.traverse(_.chat.send(cbd))
        _ <- query.finish
      } yield ()
    }

This is already pretty good, but we can replace for-comprehension inside evalTap with Apply transforms.

def answerCallbacks[F[_]: Monad: TelegramClient]: Pipe[F, Update, Update] =
  _
    .collect { case CallbackButtonSelected(_, query) => query }
    .mapFilter { query => query.data tupleLeft query }
    .evalTap { (query, cbd) =>
      query.message.traverse(_.chat.send(cbd)) *> query.finish.void
    }

We now can loosen typeclasses constraints from Monad to Applicative, since we didn't use FlatMap instance.

def answerCallbacks[F[_]: Applicative: TelegramClient]: Pipe[F, Update, Update] =
  _
    .collect { case CallbackButtonSelected(_, query) => query }
    .mapFilter { query => query.data tupleLeft query }
    .evalTap { (query, cbd) =>
      query.message.traverse(_.chat.send(cbd)) *> query.finish.void
    }

Use laws

Source

final def modifyF(key: K)(f: Option[V] => F[Option[V]])(implicit F: FlatMap[F]): F[Option[V]] =
  get(key).flatMap(maybeValue => f(maybeValue).flatTap(set(key, _)))

If we abstract it a little, we ca see following pattern:

fa.flatMap(a => f(a).flatTap(g))

It may look familiar to those, who knows the FlatMap laws. In paticular, the associativity law:

fa.flatMap(a => f(a).flatMap(g)) <-> fa.flatMap(f).flatMap(g)

If this works, does the following work also?

fa.flatMap(a => f(a).flatTap(g)) <-> fa.flatMap(f).flatTap(g)

Let's try to prove it:

fa.flatMap(a => f(a).flatTap(g))
// Unfolding `flatTap` by its definition:
// fa.flatTap(f) == fa.flatMap(a => as(f(a), a))
fa.flatMap(a => f(a).flatMap(a => as(g(a), a)))
// Using `flatMapAssociativity` law
// fa.flatMap(a => f(a).flatMap(g)) <-> fa.flatMap(f).flatMap(g)
// Where `a => as(g(a), a)` would be `g` from the law
fa.flatMap(f).flatMap(a => as(g(a), a)))
// Folding `flat(a => as(g(a), a))` to `flatTap(g)`
fa.flatMap(f).flatTap(g)
Coq proofs

Thanks to Aly (@s5bug) for Coq-based proof:

Require Import Coq.Program.Basics.

Class monad (F : Type -> Type) := {
  pure : forall { A }, A -> F A ;
  flatMap : forall { A B }, (A -> F B) -> (F A -> F B) ;
  flatMap_assoc : forall { A B C } (g : B -> F C) (f : A -> F B), flatMap (compose (flatMap g) f) = compose (flatMap g) (flatMap f)
}.

Definition flatTap { F } { mf : monad F } { A B } (f : A -> F B) (fa : F A) : F A :=
  flatMap (fun a => flatMap (fun _ => pure a) (f a)) fa.

Theorem flatTap_assoc : forall { F } { mf : monad F }
  { A B C } (g : B -> F C) (f : A -> F B),
  flatMap (compose (flatTap g) f) = compose (flatTap g) (flatMap f).
Proof.
  intros.
  unfold flatTap.
  rewrite -> flatMap_assoc.
  unfold compose.
  reflexivity.
Qed.

That proof requires Monad, because there is no map implementation (which comes from unfolding as part of flatTap) for FlatMap.

If your FlatMap has map implementation, you can loosen that proof:

Require Import Coq.Program.Basics.

Class flatMapClass (F : Type -> Type) := {
  map: forall { A B }, (A -> B) -> (F A -> F B) ;
  flatMap : forall { A B }, (A -> F B) -> (F A -> F B) ;
  flatMap_assoc : forall { A B C } (g : B -> F C) (f : A -> F B), flatMap (compose (flatMap g) f) = compose (flatMap g) (flatMap f)
}.

Definition flatTap { F } { mf : flatMapClass F } { A B } (f : A -> F B) (fa : F A) : F A :=
  flatMap (fun a => map (fun _ => a) (f a)) fa.

Theorem flatTap_assoc : forall { F } { mf : flatMapClass F }
  { A B C } (g : B -> F C) (f : A -> F B),
  flatMap (compose (flatTap g) f) = compose (flatTap g) (flatMap f).
Proof.
  intros.
  unfold flatTap.
  rewrite -> flatMap_assoc.
  unfold compose.
  reflexivity.
Qed.

Hoorah, we have proved that:

fa.flatMap(a => f(a).flatTap(g)) <-> fa.flatMap(f).flatTap(g)

Now we have the audacity justification for the rewrite of modifyF:

final def modifyF(key: K)(f: Option[V] => F[Option[V]])(implicit F: FlatMap[F]): F[Option[V]] =
  get(key).flatMap(f).flatTap(set(key, _))