Всем понятно какой практический смысл у flatMap на коллекциях, Option, IO итд. Я раньше не задумывался, зачем он может понадобиться на туплах и что он вообще делает с ними. Нашел для себя практическое применение и напишу об этом. Задачу можно решить больше чем одним способом. Не готов утверждать, что этот - лучший.

Какая задача?

Нужно посчитать количество модификаций коллекции.

Пример:

List(1, 2, 3, 4, 5).map {
  case i if i % 2 == 0 => i * 2
  case i => i
}

Результат:

List(1, 4, 3, 8, 5)

Количество измененных элементов == 2 (Хочу получить это число)

А в чём проблема?

Переписать функцию не сложно. Можно вместо map использовать foldLeft:

List(1, 2, 3, 4, 5).foldLeft(0, List.empty[Int]) {
  case (acc, i) if i % 2 == 0 => (acc._1 + 1, acc._2 :+ (i * 2))
  case (acc, i) => (acc._1, acc._2 :+ i)
}

Или separate + leftMap:

List(1, 2, 3, 4, 5)
  .map {
    case i if i % 2 == 0 => (1, i * 2)
    case i => (0, i)
  }
  .separate
  .leftMap(_.sum)

Сложность в композиции. Нужно продолжать цепочку вычислений, а у нас вместо коллекции теперь кортеж из числа и коллекции.

List(1, 2, 3, 4, 5)
  .map(functionWithTupleOutput).separate.leftMap(_.sum) // (2,List(1, 4, 3, 8, 5))
  .map(functionWithTupleOutput) // Ошибка компиляции

FlatMap для Tuple2 спешит на помощь

Изначально я написал свой велосипед, затем я решил покопаться в cats instances для tuple. Был удивлён, что в cats есть flatMap на туплах и делает он именно то, что я имплементировал. Он выглядит так (s это Semigroup):

def flatMap[A, B](fa: (X, A))(f: A => (X, B)): (X, B) = {
  val xb = f(fa._2)
  val x = s.combine(fa._1, xb._1)
  (x, xb._2)
}

FlatMap модифицирует правое значение, а левое комбинирует по правилу из Semigroup. Для Tuple3 и более, комбинирование осуществляется для всех кроме самого правого элемента

Пример использования

В примере 2 вызова flatMap - оба на туплах (Int, List[Long])

Взял Long для примера, что бы не путать значения из листа и аккумулирующий Int.

import cats.syntax.all._
import scala.util.chaining.scalaUtilChainingOps
def doubleIfMod2(longs: List[Long]): (Int, List[Long]) =
  longs
    .map {
      case i if i % 2 == 0 => (1, i * 2)
      case i => (0, i)
    }
    .separate
    .leftMap(_.sum)

def changeSignForSmallNumbers(longs: List[Long]): (Int, List[Long]) =
  longs
    .map {
      case i if i < 5 => (1, -i)
      case i => (0, i)
    }
    .separate
    .leftMap(_.sum)

val list: List[Long] =
  (1, 2, 3, 4, 5).map(_.toLong).toList

(0, list)
  .flatMap(doubleIfMod2)
  .flatMap(changeSignForSmallNumbers)
  .tap(println)

Результат выполнения (5,List(-1, -4, -3, 8, 5))

  • doubleIfMod2 - 2 модификации (2 -> 4, 4 -> 8)
  • changeSignForSmallNumbers - 3 модификации (1 -> -1, 4 -> -4, 3 -> -3)

Мысли

Увеличивает ли порог входа в проект использование подобных конструкций? Скорее всего, читателю вашего кода придётся перейти в cats и почитать, что же делает flatMap на tuple (Semigroup на левом значении - не очевидно). Но, это лучше чем написать точно такой же велосипед самому. Он будет таким же неочевидным, да еще и разным от проекта к проекту