Skip to content

Latest commit

 

History

History
388 lines (289 loc) · 24.1 KB

p07-either.md

File metadata and controls

388 lines (289 loc) · 24.1 KB

Глава 7: Тип Either

В предыдущей главе мы узнали как обрабатываются исключения в функциональном стиле. Мы узнали о тип Try. Он появился в Scala с версии 2.10. Также я упомянул и о типе Either. В этой главе мы остановимся на нём по-подробнее. Мы узнаем как и где он используется и о некоторых неприятных особенностях типа Either.

На данный момент в типе Either есть несколько серьёзных недостатков, о которых стоит знать. Они настолько серьёзны, что необходимость использования Either может вызывать сомнения. А нужен ли он нам?

Во первых, до появления Try для обработки исключений использовался тип Either и не все разработчики перешли на новый способ. Поэтому нам стоит разобраться в тонкостях и этого типа.

Кроме того, Try не равносилен Either. Try используется только для обработки исключений в функциональном стиле. На практике Try и Either дополняют друг друга. И несмотря на все недостатки Either, есть проблемы, с которыми он справляется очень хорошо.

Семантика

Как Try и Option тип Either является контейнером. Только он принимает два параметра, а не один: значение типа Either[A, B] может содержать значение типа A или значение типа B. В этом отличие от Tuple[A, B], который одновременно содержит два значения, типов A и B.

От Either наследуют всего два класса: Left и Right. Если значение Either[A, B] содержит значение типа A, тогда Either содержит Left, иначе оно содержит значение типа B обёрнутое в класс Right.

В семантике типа ничто не указывает на то, что один или другой тип представляет ошибку или успешное выполнение. На самом деле, Either обозначает тип общего назначения, представляющий результат, в котором для значений есть две возможные альтернативы. Но несмотря на это, чаще всего он используется для обработки исключений, и по соглашению Left отвечает за ошибки/исключения, а Right — за успешно вычисленное значение.

Создание значения типа Either

Значения типа Either создаются очень просто. И Left и Right являются case-классами. Так если мы хотим реализовать непробиваемую систему интернет-цензуры, мы можем сделать это так:

import scala.io.Source
import java.net.URL

def getContent(url: URL): Either[String, Source] =
  if (url.getHost.contains("google"))
    Left("Requested URL is blocked for the good of the people!")
  else
    Right(Source.fromURL(url))

Теперь, если мы вызовем getContent(new URL("http://danielwestheide.com")), то мы получим scala.io.Source обёрнутый в Right. Если мы попробуем обратиться по new URL("https://plus.google.com"), результат будет содержать Left со строкой.

Использование Either

Некоторые совсем простые вещи работают в Either точно так же как и в Try. У нас есть методы isLeft и isRight. Также мы можем выполнять сопоставление с образцом, этот способ работы с типом Either наиболее удобен:

getContent(new URL("http://google.com")) match {
  case Left(msg) => println(msg)
  case Right(source) => source.getLines.foreach(println)
}

Проекции

Мы не можем работать с Either как с коллекциями, также как и в Option или Try. Это решение вызвано тем, что Either не должно отдавать предпочтение одной или другой альтернативе.

В Try мы концентрируемся на типе результата, успешно вычисленного значения, для него определены map, flatMap и другие методы. Все они предполагают, что Try содержит Success, и если это не так, они ничего не делают, передавая далее Failure.

Поскольку в Either альтернативы равнозначны, мы сначала должны определиться с какой веткой мы хотим работать, вызовом методов left или right на значении типа Either. После этого мы получим одну из проекций LeftProjection или RightProjection, которые концентрируются на левой и правой альтернативе соответственно.

Преобразование

После того как у нас есть проекция, мы можем преобразовать её элемент:

val content: Either[String, Iterator[String]] =
  getContent(new URL("http://danielwestheide.com")).right.map(_.getLines())
// content содержит Right со строчками из Source, который был получен с помощью getContent

val moreContent: Either[String, Iterator[String]] =
  getContent(new URL("http://google.com")).right.map(_.getLines)
// moreContent содержит Left, полученный из getContent

Что бы не содержало значение Either[String, Source] в этом примере, Left или Right, оно будет преобразовано в Either[String, Iterator[String]]. Если оно содержит Right, то значение будет преобразовано, если оно содержит Left, значение останется без изменений.

То же самое мы можем выполнить и для LeftProjection:

val content: Either[Iterator[String], Source] =
  getContent(new URL("http://danielwestheide.com")).left.map(Iterator(_))
// content содержит Right с Source, в том виде, в котором он был получен из getContent

val moreContent: Either[Iterator[String], Source] =
  getContent(new URL("http://google.com")).left.map(Iterator(_))
// moreContent содержит Left с msg, полученым из getContent в Iterator'е

Теперь, если Either содержит Left, результат будет преобразован, а в случае Right оставлен без изменений. И в том и в другом случае результат значения будет Either[Iterator[String], Source]

Обратите внимание на то, что метод map определён на проекциях, а не на самом типе Either, но он возвращает значение типа Either, а не проекции. В этом Either уходит от привычной аналогии с коллекциями. Так происходит потому, что в Either альтернативы должны оставаться равнозначными. Но Вы уже наверное догадываетесь о том, какие проблемы могут стоять за этим решением. к тому же, если мы хотим вызвать несколько методов map, flatMap и других методов, нам придётся каждый раз указывать какую проекцию мы хотим использовать.

Метод flatMap

Для проекций также определён метод flatMap, который позволяет избежать проблемы вложенных структур при вызове map.

Прошу потерпеть, но сейчас мы рассмотрим очень надуманный пример. Допустим нам хочется узнать среднее число строк для двух моих статей. Нам ведь давно хотелось узнать об этом, не так ли? Мы можем решить эту сложнейшую задачу, примерно так:

val part5 = new URL("http://t.co/UR1aalX4")
val part6 = new URL("http://t.co/6wlKwTmu")
val content = getContent(part5).right.map(a =>
  getContent(part6).right.map(b =>
    (a.getLines().size + b.getLines().size) / 2))

В результате мы получим значение типа Either[String, Either[String, Int]], что содержит вложенный Either в правой альтернативе. Мы можем избавиться от этой вложенности вызовом метода joinRight, также определён и метод joinLeft.

Однако мы можем не создавать вложенную структуру изначально. Если мы вызовем flatMap на RightProjection, то мы полуим более приятный результат. Это приведёт к тому что, мы избавимся от Right во внутреннем Either.

val content = getContent(part5).right.flatMap(a =>
  getContent(part6).right.map(b =>
    (a.getLines().size + b.getLines().size) / 2))

Теперь переменная content имеет тип Either[String, Int] и с ней гораздо приятнее работать, например, при сопоставлении с образцом.

For-генераторы

Скорее всего, for-генераторы уже успели вам понравиться. Они предоставляют набор простых средств для работы с различными типами. Также мы можем использовать for-генераторы для проекций, но, к сожалению, это будет не так элегантно, придётся иметь дело с корявыми костылями.

Давайте перепишем наш пример с flatMap через генераторы:

def averageLineCount(url1: URL, url2: URL): Either[String, Int] =
  for {
    source1 <- getContent(url1).right
    source2 <- getContent(url2).right
  } yield (source1.getLines().size + source2.getLines().size) / 2

Не так плохо. Обратите внимание на вызов right в каждом из генераторов.

Теперь немного перепишем это выражение. Поскольку возвращаемый результат слишком многословен, давайте определим локальные переменные для его упрощения:

def averageLineCountWontCompile(url1: URL, url2: URL): Either[String, Int] =
  for {
    source1 <- getContent(url1).right
    source2 <- getContent(url2).right
    lines1 = source1.getLines().size
    lines2 = source2.getLines().size
  } yield (lines1 + lines2) / 2

Но это выражение не скомпилируется! Причина станет понятной если мы избавимся от синтаксического сахара for-генераторов. Исходное выражение превратится в нечто менее наглядное:

def averageLineCountDesugaredWontCompile(url1: URL, url2: URL): Either[String, Int] =
  getContent(url1).right.flatMap { source1 =>
    getContent(url2).right.map { source2 =>
      val lines1 = source1.getLines().size
      val lines2 = source2.getLines().size
      (lines1, lines2)
    }.map { case (x, y) => x + y / 2 }
  }

Проблема в том, что при определении переменных внутри for-генератора появляется ещё один промежуточный вызов map. Он вызывается на результате предыдущего вызова map, который вернул Either, а не RightProjection. Но для Ether метод map не определён, на что и пожаловался компилятор.

И вот из Either показалась корявая усмешка тролля. В этом примере, мы можем обойтись и без локальных переменных. Если они нам действительно нужны, мы можем обойти проблему, заменив простые переменные генераторами:

def averageLineCount(url1: URL, url2: URL): Either[String, Int] =
  for {
    source1 <- getContent(url1).right
    source2 <- getContent(url2).right
    lines1 <- Right(source1.getLines().size).right
    lines2 <- Right(source2.getLines().size).right
  } yield (lines1 + lines2) / 2

Об этой проблеме необходимо помнить. Она не ставит крест на Either, но может доставить массу хлопот, если вы не понимаете причину её появления.

Другие методы

На проекциях определены и другие полезные методы:

Мы можем преобразовать Either в Option вызовом метода toOption на одной из проекций. К примеру, у нас есть значение e типа Either[A, B]. Выражение e.right.toOption вернёт Option[B]. Если Either[A, B] содержит Right, результат окажется Some. Если Left, то мы получим None. Аналогично и для LeftProjection. Для преобразование в последовательность из одного элемента воспользуйтесь методом toSeq.

Свёртка

Для преобразования Either в независимости от того какую альтернативу оно содержит, мы можем воспользоваться методом fold, принимающим две функции с одним и тем же типом для результата. Первая функции вызывается, если значение содержит Left и вторая функция — в случае Right.

Рассмотрим на примере объединения двух операций, определённых на левой и правой проекции:

val content: Iterator[String] =
  getContent(new URL("http://danielwestheide.com")).fold(Iterator(_), _.getLines())

val moreContent: Iterator[String] =
  getContent(new URL("http://google.com")).fold(Iterator(_), _.getLines())

В этом примере мы преобразуем Either[String, Source] в Iterator[String], вне зависимости от значения Either. Также мы могли вернуть из функций Either или выполнить побочные эффекты с результатом Unit для двух функций. Метод fold является отличной альтернативой сопоставлению с образцом.

Когда использовать Either

Теперь, когда мы узнали как использовать Either, давайте рассмотрим несколько частных случаев его применения.

Обработка исключений

Мы можем пользоваться Either для обработки исключений вместо Try. У Either есть одно небольшое преимущество в том, что мы можем определить тип исключений более точно, в то время как в Try всё время используется самый общий тип Throwable. Поэтому Either хорошо применять, когда тип исключений заранее известен.

Необходимо определить специальный метод, воспользовавшись очень полезным объектом exception из пакета scala.util.control:

import scala.util.control.Exception.catching
def handling[Ex <: Throwable, T](exType: Class[Ex])(block: => T): Either[Ex, T] =
  catching(exType).either(block).asInstanceOf[Either[Ex, T]]

Нам нужен этот метод из-за того, что несмотря на то, что методы, определённые в scala.util.Exception, позволяют нам обрабатывать специфические типы исключений, результирующий на этапе компиляции тип исключения всегда будет Throwable.

С помощью этого метода мы можем указывать на тип исключения в Either:

import java.net.MalformedURLException
def parseURL(url: String): Either[MalformedURLException, URL] =
  handling(classOf[MalformedURLException])(new URL(url))

Также наши исключения не обязательно могут быть вызваны отваливающимися функциями из чужих библиотек, как в примере выше. В тех примерах мы лишь перехватывали исключения, которые случались в библиотечных функциях, и оборачивали их в Left. Вместо этого мы можем определить свой тип ошибок, предпочтительно в виде case-класса. После чего мы можем возвращать его из функции, поместив в конструктор Left, при выполнении некоторого условия.

Приведём пример:

case class Customer(age: Int)

class Cigarettes

case class UnderAgeFailure(age: Int, required: Int)

def buyCigarettes(customer: Customer): Either[UnderAgeFailure, Cigarettes] =
  if (customer.age < 16) Left(UnderAgeFailure(customer.age, 16))
  else Right(new Cigarettes)

Для обработки неожиданных исключений лучше использовать Try. Он лишён всех недостатков Either.

Обработка коллекций

Обычно, Either хорошо подходит для работы с коллекциями, если в какой-то момент что-то может пойти не так, но при этом не возникает ошибка, исключающая дальнейшую обработку элементов коллекции.

Предположим, что для нашего интернет-цензора определён чёрный список:

type Citizen = String
case class BlackListedResource(url: URL, visitors: Set[Citizen])

val blacklist = List(
  BlackListedResource(new URL("https://google.com"), Set("John Doe", "Johanna Doe")),
  BlackListedResource(new URL("http://yahoo.com"), Set.empty),
  BlackListedResource(new URL("https://maps.google.com"), Set("John Doe")),
  BlackListedResource(new URL("http://plus.google.com"), Set.empty)
)

BlackListedResource представляет набор запрещённых адресов с указанием лиц, пытавшихся по ним обратиться.

Теперь мы хотим найти проблемных пользователей, то есть тех, что пытались попасть на запрещённые страницы. В то же время мы хотим найти подозрительные страницы — те страницы, в которые никто не обращался. Мы предполагаем, что пользователи могут обойти наш фильтр и нам нужно разобраться с этой проблемой:

Мы можем обработать наш список следующим образом:

val checkedBlacklist: List[Either[URL, Set[Citizen]]] =
  blacklist.map(resource =>
    if (resource.visitors.isEmpty) Left(resource.url)
    else Right(resource.visitors))

Мы создали последовательность значений Either. Левые альтернативы представляют подозрительные адреса и правые — множества проблемных пользователей. Теперь мы можем совсем просто установить и проблемных пользователей и подозрительные страницы:

val suspiciousResources = checkedBlacklist.flatMap(_.left.toOption)
val problemCitizens = checkedBlacklist.flatMap(_.right.toOption).flatten.toSet

Мы видим, что Either отлично справляется с более общими задачами, которые выходят за рамки обработки исключений.

Итоги

Мы узнали как и где использовать Either, а также о его недостатках. И Either совсем не лишён недостатков, но итоговое решение, нужен ли этот тип в Вашем коде или нет, всегда остаётся за Вами.

На практике, благодаря Try, для Either остаётся не так много работы. Но всё-таки нам важно знать и об этом типе. Могут случиться и ситуации, в которых он станет вашим незаменимым помощником, а также это полезно для понимания кода Scala для версий ниже 2.10, там где он использовался для обработки исключений.