Skip to content

Latest commit

 

History

History
393 lines (293 loc) · 26 KB

p05-option.md

File metadata and controls

393 lines (293 loc) · 26 KB

Глава 5: Тип Option

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

flatMap и неопределённые значения

Давайте проделаем то же самое для пола:

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, поскольку пользователь не определён

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

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