Как писать хвостовые рекурсивные функции при работе внутри монад

В общем, у меня проблемы с пониманием того, как писать хвостовые рекурсивные функции при работе «внутри» монад. Вот краткий пример:

Это небольшой пример приложения, которое я пишу, чтобы лучше понять FP в Scala. Прежде всего пользователю предлагается ввести Team, состоящий из 7 Players. Эта функция рекурсивно считывает ввод:

import cats.effect.{ExitCode, IO, IOApp}
import cats.implicits._

case class Player (name: String)
case class Team (players: List[Player])

/**
  * Reads a team of 7 players from the command line.
  * @return
  */
def readTeam: IO[Team] = {
  def go(team: Team): IO[Team] = { // here I'd like to add @tailrec
    if(team.players.size >= 7){
      IO(println("Enough players!!")) >>= (_ => IO(team))
    } else {
      for {
        player <- readPlayer
        team   <- go(Team(team.players :+ player))
      } yield team
    }
  }
  go(Team(Nil))
}

private def readPlayer: IO[Player] = ???

Теперь то, чего я хотел бы достичь (в основном в образовательных целях), - это написать @tailrec нотацию перед def go(team: Team). Но я не вижу возможности использовать рекурсивный вызов в качестве моего последнего оператора, потому что последний оператор, насколько я могу видеть, всегда должен «поднимать» мой Team в монаду ввода-вывода.

Любая подсказка будет принята с благодарностью.


person Florian Baierl    schedule 02.02.2019    source источник


Ответы (1)


Во-первых, в этом нет необходимости, потому что IO специально разработан для поддержки монадической рекурсии, безопасной для стека. Из документов:

IO попал в ловушку в своей flatMap оценке. Это означает, что вы можете безопасно вызывать flatMap в рекурсивной функции произвольной глубины, не опасаясь взорвать стек ...

Таким образом, ваша реализация будет отлично работать с точки зрения безопасности стека, даже если вместо семи игроков вам понадобится 70000 игроков (хотя в этот момент вам, возможно, придется побеспокоиться о куче).

Однако это на самом деле не отвечает на ваш вопрос, и, конечно, даже @tailrec никогда не необходим, поскольку все, что он делает, - это проверка того, что компилятор делает то, что, по вашему мнению, он должен делать.

Хотя невозможно написать этот метод таким образом, чтобы его можно было аннотировать с помощью @tailrec, вы можете получить аналогичную уверенность, используя tailRecM Cats. Например, следующее эквивалентно вашей реализации:

import cats.effect.IO
import cats.syntax.functor._

case class Player (name: String)
case class Team (players: List[Player])

// For the sake of example.
def readPlayer: IO[Player] = IO(Player("foo"))

/**
  * Reads a team of 7 players from the command line.
  * @return
  */
def readTeam: IO[Team] = cats.Monad[IO].tailRecM(Team(Nil)) {
  case team if team.players.size >= 7 =>
    IO(println("Enough players!!")).as(Right(team))
  case team =>
    readPlayer.map(player => Left(Team(team.players :+ player)))
}

Это говорит: «Начните с пустой команды и несколько раз добавляйте игроков, пока у нас не будет нужного числа», но без каких-либо явных рекурсивных вызовов. Пока экземпляр монады является законным (согласно определению Cats - есть некоторый вопрос о том, принадлежит ли tailRecM даже Monad), вам не нужно беспокоиться о безопасности стека.

Кстати, fa.as(b) эквивалентно fa >>= (_ => IO(b)), но более идиоматично.

Также в качестве примечания (но, возможно, более интересного) вы можете написать этот метод еще более кратко (и, на мой взгляд, более четко) следующим образом:

import cats.effect.IO
import cats.syntax.monad._

case class Player (name: String)
case class Team (players: List[Player])

// For the sake of example.
def readPlayer: IO[Player] = IO(Player("foo"))

/**
  * Reads a team of 7 players from the command line.
  * @return
  */
def readTeam: IO[Team] = Team(Nil).iterateUntilM(team =>
  readPlayer.map(player => Team(team.players :+ player))
)(_.players.size >= 7)

Опять же, здесь нет явных рекурсивных вызовов, и он даже более декларативен, чем версия tailRecM - он просто «выполняет это действие итеративно, пока данное условие не будет выполнено».


Один постскриптум: вы можете задаться вопросом, зачем вам когда-либо использовать tailRecM, когда IO#flatMap безопасен для стека, и одна из причин заключается в том, что вы можете когда-нибудь решить сделать свою программу универсальной в контексте эффекта (например, с помощью шаблона finally без тегов). В этом случае вы не должны предполагать, что flatMap ведет себя так, как вы этого хотите, поскольку законность для cats.Monad не требует, чтобы flatMap был безопасным для стека. В этом случае было бы лучше избегать явных рекурсивных вызовов через flatMap и вместо этого выбирать tailRecM или iterateUntilM и т. Д., Поскольку они гарантированно безопасны для стека для любого законного монадического контекста.

person Travis Brown    schedule 02.02.2019
comment
Вау, большое спасибо за подробный ответ. Я обязательно буду использовать iterateUntilM в будущем - гораздо легче читать. - person Florian Baierl; 02.02.2019