В течение последних нескольких недель мы сильно разогнались и узнали много нового о продвинутых
возможностях языка. Мы подробно изучили сопоставление с образцом и экстракторы.
Давайте немного сбавим обороты и познакомимся с одной из основных основных особенностей Scala: тип Option
.
Этот тип рассматривался в курсе на Coursera в примере с Map API. Мы так же столкнулись с ним при определении экстракторов.
Несмотря на это, нам ещё многое предстоит узнать об этом типе. Вы можете спросить: зачем столько шума вокруг
этого типа, неужели он настолько лучше других способов обработки неопределённых значений. Также
Вам может быть непонятно как работать со значениями типа Option
. В настоящей главе мы ответим
на все эти вопросы и узнаем всё, что необходимо знать о типе Option
.
Если вы в прошлом программировали на Java, наверняка вам встречалось исключение NullPointerException
,
в той или иной форме оно присутствует и в других языках. Обычно это связано с тем, что метод
возвращает нулевой указатель (null
) там, где мы совсем этого не ждём. Нулевой указатель часто
используется для представления неопределённого значения.
В некоторых языках для работы с нулевыми указателями предусмотрен специальный синтаксис.
В Groovy есть специальная форма доступа к методам значений, которые могут оказаться нулевыми
указателями. Так выражение foo?.bar?.baz
не приведёт к исключению, если foo
или его поле bar
являются нулевыми указателями. Однако мы напоремся на исключение, если мы забудем воспользоваться этой
специальной формой записи и компилятор языка не напомнит нам об этом.
В Clojure считается, что нулевой указатель nil
может выступать в качестве любого "пустого" значения:
пустого списка или пустого ассоциативного массива, если он используется как ассоциативный массив.
Это позволяет нулевому указателю "всплывать" на поверхность иерархии вызовов функций. Обычно в этом
нет ничего плохого, но иногда это приводит к исключениям, гораздо выше в иерархии вызовов, где
использование нулевых указателей недопустимо.
Разработчики Scala попытались решить эту проблему, совсем исключив нулевые указатели из языка.
В Scala определён специальный тип Option[A]
для частично определённых значений типа A
.
Значение типа Option[A]
представляет собой контейнер для значений типа A
. Если значение
определено, то оно содержит экземпляр класса Some[A]
, содержащий само значение типа A
,
если же значение не определено, то оно содержит объект None
.
Мы объявляем возможность присутствия неопределённых значений на уровне типов. Это приводит к тому, что компилятор напомнит об этом нам и нашим коллегам, которые работают с нашим кодом. Мы не можем пользоваться неопределёнными значениями, так словно это обычные значения.
Всегда пользуйтесь типом Option
! Никогда не обозначайте неопределённые
значения с помощью нулевых указателей.
Мы можем создать значение типа Option[A]
для простого значения, обернув его в конструктор Some
:
val greeting: Option[String] = Some("Hello world")
Или если мы знаем, что значение не определено, мы можем присвоить результату объект None
:
val greeting: Option[String] = None
Но может статься так, что нам придётся работать с унаследованным Java кодом или библиотеками
на других JVM-языках. Специально для этого случая в объекте-компаньоне Option
определён конструктор,
который обратит нулевое значение в None
и все остальные значения завернёт в конструктор Some
.
val absentGreeting: Option[String] = Option(null) // absentGreeting will be None
val presentGreeting: Option[String] = Option("Hello!") // presentGreeting will be Some("Hello!")
Это всё хорошо, но как пользоваться неопределёнными значениями в коде? Начнём с примера. Задача будет скучной, так чтобы ничто не отвлекало нас от важных нюансов:
Предположим, что нас наняли в команду хипстеров и мы работаем над новым супер-стартапом.
Первое, что нам нужно сделать, это реализовать базу пользователей. Нам нужно уметь
находить пользователя по уникальному идентификатору, и запросы могут быть с неправильными
идентификаторами. Самое время для того, чтобы воспользоваться типом Option[User]
для
нашего метода поиска. Черновой набросок для базы пользователей может выглядеть примерно так:
case class User(
id: Int,
firstName: String,
lastName: String,
age: Int,
gender: Option[String])
object UserRepository {
private val users = Map(1 -> User(1, "John", "Doe", 32, Some("male")),
2 -> User(2, "Johanna", "Doe", 30, None))
def findById(id: Int): Option[User] = users.get(id)
def findAll = users.values
}
Теперь, когда у нас есть значение типа Option[User]
и нам нужно что-то с ним сделать. Но как?
Один из вариантов, воспользоваться методом isDefined
, что проверяет определено ли наше значение,
и если оно определено извлечь его с помощью метода get
:
val user1 = UserRepository.findById(1)
if (user1.isDefined) {
println(user1.get.firstName)
} // will print "John"
Похожим образом устроен тип Optional
в Java-библиотеке Guava. Вы не ошибётесь, предположив, что в Scala
есть гораздо более элегантные методы. Этот пример не многим лучше использования нулевых указателей.
Мы можем забыть про метод isDefined
и словить исключение на этапе выполнения после вызова get
.
Нам не стоит пользоваться этими методами для работы с Option
!
Часто у нас есть запасной вариант, на случай если значение не определено. Как раз для
этого и существует метод getOrElse
:
val user = User(2, "Johanna", "Doe", 30, None)
println("Gender: " + user.gender.getOrElse("not specified")) // will print "not specified"
Обратите внимание на то, что getOrElse
принимает значение по имени. Это означает, что
значение по умолчанию будет вычислено только в том случае, если метод вызван на объекте None
Нет повода волноваться об эффективности вычисления значения по умолчанию — оно будет
вычислено только тогда, когда оно действительно нужно.
Класс Some
определён как case
-класс, мы можем воспользоваться им в обычном сопоставлении с образцом
или в любом другом месте, где могут встретиться образцы. Давайте перепишем наш пример с помощью
сопоставления с образцом:
val user = User(2, "Johanna", "Doe", 30, None)
user.gender match {
case Some(gender) => println("Gender: " + gender)
case None => println("Gender: not specified")
}
Вспомнив о том, что сопоставление с образцом — это выражение, которое возвращает результат,
мы можем избавиться от дублирования метода println
:
val user = User(2, "Johanna", "Doe", 30, None)
val gender = user.gender match {
case Some(gender) => gender
case None => "not specified"
}
println("Gender: " + gender)
Надеюсь, что вы обратили внимание на многословность этого примера. Использование
сопоставления с образцом считается признаком не идиоматичного кода. Поэтому, несмотря
на всю прелесть сопоставления с образцом, при работе с типом Option
гораздо лучше
воспользоваться другими альтернативами.
Есть очень элегантный способ — неопределённые значения могут быть представлены
как коллекции в for
-генераторах.
Пока нам ещё не встретились примеры элегантной или идиоматичной работы с неопределёнными значениями. Они вот-вот появятся.
Я уже говорил о том, что Option[A]
является контейнером для значений типа A
.
Вы можете думать об этом как об урезанной коллекции, что может либо быть пустой, либо
содержать только один элемент типа A
. Это очень мощная идея!
Несмотря на то, что на уровне типов Option
не является коллекцией в Scala,
для этого типа определены многие методы знакомые нам по работе со стандартными
коллекциями, такими как списки или множества. Мы даже можем преобразовать Option
в список.
Итак что же у нас есть?
Если нам нужно выполнить какое-то действие только в том случае, если значение определено,
мы можем воспользоваться знакомым методом для коллекций foreach
:
UserRepository.findById(2).foreach(user => println(user.firstName)) // напечатает "Johanna"
Функция переданная в foreach
будет вызвана лишь один раз, если Option
содержит Some
или совсем не вызвана в случае None
.
Что по настоящему прекрасно в аналогии с коллекциями, так это то, что мы можем работать с неопределёнными значениями в функциональном стиле, причём теми же методами, что определены на списках, множествах и других коллекциях
Точно так же как мы можем отображать списки из List[A]
в List[B]
с помощью метода map
,
мы можем отображать Opyion[A]
в Option[B]
. Это означает, что если значение в Option[A]
определено мы получим преобразованное значение Some[B]
, иначе мы получим None
.
Сравнивая Option
со списками, можно заметить что None
эквивалентно пустому списку:
когда мы преобразуем пустой список List[A]
, мы получаем пустой список List[B]
.
Когда мы преобразуем Option[A]
, которое содержит None
, мы получаем Option[B]
,
которое содержит None
.
Давайте узнаем возраст пользователя:
val age = UserRepository.findById(1).map(_.age) // возраст равен Some(32)
Давайте проделаем то же самое для пола:
val gender = UserRepository.findById(1).map(_.gender) // пол равен Option[Option[String]]
Мы получили значение типа Option[Option[String]]. Но почему?
У нас есть контейнер Option
, что содержит значение типа User
, и мы преобразуем это
значение с помощью частично определённого методом gender
, что переводит User
в Option[String]
.
Но помешает ли нам вся эта вложенность? Ничуть. Как и в коллекциях у нас есть метод flatMap
,
также как мы бы преобразовали List[List[A]]
в 'List[B]' мы можем преобразовать и Option[Option[A]]
:
val gender1 = UserRepository.findById(1).flatMap(_.gender) // пол равен Some("male")
val gender2 = UserRepository.findById(2).flatMap(_.gender) // пол равен None
val gender3 = UserRepository.findById(3).flatMap(_.gender) // пол равен None
Мы получили значение типа Option[String]
. Если пользователь и его пол определены, мы получим
значение завёрнутое в один Some
. Если же либо пользователь либо его пол не определены мы
получим None
.
Для того чтобы разобраться как это работает, давайте посмотрим, что происходит когда мы
преобразуем с помощью flatMap
список списков строк. Вспомните о том, что Option
это такая же коллекция как список.
val names: List[List[String]] =
List(List("John", "Johanna", "Daniel"), List(), List("Doe", "Westheide"))
names.map(_.map(_.toUpperCase))
// results in List(List("JOHN", "JOHANNA", "DANIEL"), List(), List("DOE", "WESTHEIDE"))
names.flatMap(_.map(_.toUpperCase))
// results in List("JOHN", "JOHANNA", "DANIEL", "DOE", "WESTHEIDE")
При использовании flatMap
элементы были преобразованы в один список строк.
Естественно, что от пустого списка ничего не останется.
Но вернёмся к типу Option
. Посмотрите, что произойдёт если мы отобразим
список частично определённых строк:
val names: List[Option[String]] = List(Some("Johanna"), None, Some("Daniel"))
names.map(_.map(_.toUpperCase)) // List(Some("JOHANNA"), None, Some("DANIEL"))
names.flatMap(xs => xs.map(_.toUpperCase)) // List("JOHANNA", "DANIEL")
Если мы просто отобразим список неопределённых значений результат сохранит
громоздкий тип List[Option[String]]
. С помощью flatMap
мы можем поместить
все определённые результаты в один список. Так мы можем оставить лишь Some
-значения.
А теперь вернитесь, к примеру с flatMap
для Option
. Разобрались с тем что происходит?
Мы можем фильтровать неопределённые значения точно так же как и списки.
Если значение Option[A]
определено и предикат возвращает истину, мы получим Some[A]
.
Если значение содержит None
или предикат возвращает ложь на Some[A]
, всё
выражение вернёт None
.
UserRepository.findById(1).filter(_.age > 30) // None, поскольку age <= 30
UserRepository.findById(2).filter(_.age > 30) // Some(user), поскольку age > 30
UserRepository.findById(3).filter(_.age > 30) // None, поскольку пользователь не определён
Теперь, когда нам известно, что Option
может выступать в роли коллекции и
у нас есть методы map
, flatMap
, 'filter' и другие методы знакомые нам по работе с коллекциями,
Вы наверняка догадываетесь о том, что неопределённые значения могут быть использованы
в for
-генераторах. Обычно, это наиболее наглядный способ работы с Option
.
в особенности если нам нужно построить цепочку из многих вызовов методов map
,
flatMap
или filter
. Если вызов всего один, предпочтительнее воспользоваться именно им.
Например, для того чтобы узнать пол для данного пользователя мы можем воспользоваться
следующим for
-генераторм:
for {
user <- UserRepository.findById(1)
gender <- user.gender
} yield gender // вернёт Some("male")
Возможно вы заметили, что эта запись эквивалентна вложенному вызову flatMap
.
Если UserRepository
вернёт None
или Gender
равен None
итоговый результат
также будет None
. Но в данном примере пользователь и пол определены, поэтому мы
получим Some("male")
Если мы хотим узнать пол для всех пользователей, мы можем пробежаться по всем пользователям и извлечь пол если он определён:
for {
user <- UserRepository.findAll
gender <- user.gender
} yield gender
Поскольку по-сути происходит вызов flatMap
, тип результата равен List[String]
,
а само выражение вернёт List("male")
, поскольку пол определён лишь для одного пользователя.
Возможно из третьей части этого выпуска вы помните, что левая часть генератора является образцом. Поэтому мы можем извлекать неопределённые значения с помощью образцов:
Мы можем переписать предыдущий пример так:
for {
User(_, _, _, _, Some(gender)) <- UserRepository.findAll
} yield gender
Поставив Some
в левой части генератора, мы убрали из результирующей коллекции всех пользователей,
для которых пол не определён.
Мы можем строить цепочки из Option
, это напоминает цепочки
из частично определённых функций. Мы вызываем метод orElse
на значении типа Option
,
передав аргументом другое неопределённое значение. Если первое значение вернёт None
,
вместо него будет использовано второе. Иначе будет возвращено первое значение.
Хорошим примером использования этой техники является извлечение данных из
нескольких источников. В нашем примере мы пытаемся извлечь данные из файла
инициализации настроек. Также мы вызываем orElse
, указав альтернативный источник:
case class Resource(content: String)
val resourceFromConfigDir: Option[Resource] = None
val resourceFromClasspath: Option[Resource] = Some(Resource("I was found on the classpath"))
val resource = resourceFromConfigDir orElse resourceFromClasspath
Особенно хорошо это работает, если число значений больше двух.
Если их меньше, стоит подумать о том, чтобы указать значение по умолчанию
с помощью функции getOrElse
.
Я надеюсь, что после построения этой статьи у Вы сможете успешно пользоваться типом Option
,
понимать код других разработчиков, а Ваш код станет гораздо более наглядным. Главная идея
заключается в том, что существует очень простой, но в то же время очень мощный и элегантный,
способ работы с коллекциями, общий для списков, множеств, ассоциативных массивов,
неопределённых значений и для многих других типов, о чём вы узнаете из следующих статей.