В предыдущей главе мы узнали как обрабатываются исключения в функциональном стиле.
Мы узнали о тип 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
создаются очень просто. И 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
точно так же как и в 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
, который позволяет избежать проблемы вложенных структур
при вызове 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
-генераторы для проекций,
но, к сожалению, это будет не так элегантно, придётся иметь дело с корявыми костылями.
Давайте перепишем наш пример с 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
для обработки исключений вместо 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,
там где он использовался для обработки исключений.