Введение в Scala.

С нуля до распределенных приложений.

Опциональные значения. Монады

Рассмотрим, казалось бы, простую проблему работы с опциональными значениями. Допустим, у нас есть таблица данных о людях с обязательным полем name и опциональными полями nickname, height и weight.

Представить такой объект можно следующим классом:

case class Person(name: String, nickname: String, height: Double, weight: Double)

В этом примере опциональность полей не выражена в типах и может поддерживаться только негласными соглашениями. Например:

  • отсутствующее значение nickname будет представлено как null
  • отсутствующие значения height и weight будут представлены значением 0.0

Клиентский код, использующий этот объект, всегда находится под угрозой NullPointerException и многих других:

def isTall(p: Person): Boolean =
  if (p.height != 0.0) p.height > 1.9
  else ??? // что возвращать тут?

// null pointer exception, забыли проверить на null
def nicknameLength(p: Person) = p.nickname.length

// division by zero exception, забыли проверить на 0
def calcBMI(p: Person) = p.weight / (p.height * p.height)

Maybe

Попробуем ввести понятие “возможно отсутствующего значения” в систему типов.

abstract class Maybe[+T]
case class Just[T](get: T) extends Maybe[T]
case object Not extends Maybe[Nothing]
// Nothing - это специальный тип в Scala, являющийся производным от всех остальных типов

Тогда указанные выше примеры можно переписать так:

case class Person(
  name: String,
  nickname: Maybe[String],
  height: Maybe[Double],
  weight: Maybe[Double])

def isTall(p: Person): Maybe[Boolean] = p.height match {
  case Just(h) => Just(h > 1.9)
  case Not => Not
}

def nicknameLength(p: Person): Maybe[Int] = p.nickname match {
  case Just(nickname) => Just(nickname.length)
  case Not => Not
}

Отметим сходство реализации этих методов. Их можно упростить добавив метод map:

abstract class Maybe[+T] {
  def map[U](f: T => U): Maybe[U]
}
case class Just[T](get: T) extends Maybe[T] {
  def map[U](f: T => U) = Just(f(get))
}

case object Not extends Maybe[Nothing] {
  def map[U](f: Nothing => U) = Not // всегда остается Not
}

def isTall(p: Person): Maybe[Boolean] = p.height.map(_ > 1.9)
def nicknameLength(p: Person): Maybe[Int] = p.nickname.map{_.length}

def calcBMI(p: Person): Maybe[Maybe[Double]] =
  p.weight.map { w =>
    p.height.map { h => w / (h * h) }
  }

Как избавиться от вложенного Maybe? Для этого нам понадобится добавление метода flatMap в реализацию:

// Maybe[+T]
def flatMap[U](f: T => Maybe[U]): Maybe[U]

// Just[T]
def flatMap[U](f: T => Maybe[U]): Maybe[U] = f(get)

// Not
def flatMap[U](f: Nothing => Maybe[U]): Maybe[U] = Not
def calcBMI(p: Person): Maybe[Double] =
  p.weight.flatMap { w =>
    p.height.map { h => w / (h * h) }
  }

Отметим, что применяя эти операции мы сохраняем контекст (возможное отсутствие значения).

Монады

Корни понятия уходят в теорию категорий.

Описанный выше класс Maybe явлется примером монады в Scala.

Монада - это способ решения следующей проблемы: если у нас есть значение A в контексте M, то как нам работать с этим значением не теряя контекста.

Попросту говоря, в Scala монадой называется тип M[A] у которого определены методы map и flatMap со следующими сигнатурами:

def map[B](f: A => B): M[B]
def flatMap[B](f: A => M[B]): M[B]

В примерах выше контекстом M является класс Maybe.

Самые распространенные примеры монад в языке Scala:

  • коллекции (List, Set)
  • “опциональное значение” (Option - название вышеприведенного Maybe в стандартной библиотеке)
  • отложенное вычисление (Future)

Названия методов map и flatMap в примере с Maybe выше выбраны неспроста.

Scala позволяет работать с ними при помощи выражения for:

val optionalX: Maybe[Int] = Just(5)
val optionalY: Maybe[Int] = Just(10)
val optionalZ: Maybe[Int] = Just(12)

val b: Maybe[Boolean] = for {
  x <- optionalX
  y <- optionalY
  z <- optionalZ
} yield (x + y) > z

Это разворачивается компилятором в следующее выражение:

val b: Maybe[Boolean] =
  optionalX.flatMap{ x =>
    optionalY.flatMap { y =>
        optionalZ.map(z => (x + y) > z)
    }
  }

По правилам:

  1. последний генератор (выражение типа a <- containerA) в for преобразуется в вызов метода map.
  2. все предыдущие генераторы преобразуются в вызов flatMap.

Контекст

Выражения “сохраняющие контекст” встречаются в Scala повсеместно и for-выражения - общепринятое средство работы с ними. Хоть и не сразу кажущееся практичным, понимание монад будет впоследствии очень полезно в функциональном программировании, особенно в проектировании библиотек.