Иногда при работе с Java возникает NullPointerException, это происходит потому, что некоторые методы могут возвращать null, а не ожидаемое вами значение и такое поведение не проверяется в использующем такую функцию коде. Значение null часто используется для представления отсутствующего значения.
При этом подобные концепции встречаться и в других языках, более того некоторые языки (например: Groovy) имеют встроенную поддержку конструкций для работы с такими значениями.
Scala пошла другим путем и создала свой тип для представления того, что значение может быть или его может не быть это трейт Option[A].
Option[A] – это контейнер для значения типа A, при этом если значение присутствует, то Option[A] – это экземпляр Some[A] содержащий значение типа A . Если значение отсутствует то Option[A] – это объект None.
Такой подход обязует всех разработчиков использующих док учитывать такую природу значений в своем коде, при этом работа с таким типом контролируется компилятором, что делает невозможным не правильную трактовку значений.
Тип Option – обязателен!!! НЕ ИСПОЛЬЗУЙТЕ null!
Создание option
Обычно для создания экземпляра Option[A] напрямую создают экземпляр класса Some с конкретным значением:
val greeting: Option[String] = Some(“Hello world”)
Или в случае если нужно указать на отсутствие значения используют объект None.
val greeting: Option[String] = None
Для совместимости с другими классами и библиотеками JVM, которые могут использовать null как признак отсутствия значения в Scala встроен объект компаньон Option который позволяет создавать значение None если передано значение null или оборачивать все другие значение в оболочку Some:
val absentGreeting: Option[String] = Option(null) // absentGreeting will be None
val presentGreeting: Option[String] = Option(“Hello!”) // presentGreeting will be Some(“Hello!”)
Работа с опциональными значениями
Рассмотрим гипотетический пример, пускай нам нужно создать класс репозиторий пользователей с методом, который возвращает нам пользователя системы по его идентификатору, в качестве возвращаемого значениями мы используем класс Option[User], чтобы иметь возможность отобразить ситуацию, когда пользователя мы не нашли. Реализация:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
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 будет использование метода isDefined для проверки того, что значение не None и затем использование метода get, например, так:
1 2 3 4 5 6 7 |
val user1 = UserRepository.findById(1) if (user1.isDefined) { println(user1.get.firstName) } // will print "John" |
Однако применение метода get не самый оптимальный вариант, так как перед его вызовами можно забыть проверить isDefined и таким образом получить ошибку времени выполнения.
Поэтому такой подход не рекомендован к использованию и его следует избегать!
Задание значения по умолчанию
Очень часто Вам необходимо работать или с полученным значением или с каким-то значением по умолчанию (если переданное значение отсутствует). Для таких целей существует метод getOrElse класса Option например:
val user = User(2, “Johanna”, “Doe”, 30, None)
println(“Gender: ” + user.gender.getOrElse(“not specified”)) // will print “not specified”
Стоит заметить, что что значение по умолчанию, которое используется в методе getOrElse передается как параметр по имени, что означает, что оно вычисляется только тогда когда функции getOrElse действительно нужно значение None. Таким образом снижается нагрузка, так как значение будет запрошено только тогда, когда оно действительно нужно. Что позволяет делать отложенное создание значения.
Сравнение по шаблону
Так как класс Some это case класс, то его можно применять в шаблонах в операторе сравнения по шаблонам или в других местах где допустимо применение шаблонов, например:
1 2 3 4 5 6 7 8 9 |
val user = User(2, "Johanna", "Doe", 30, None) user.gender match { case Some(gender) => println("Gender: " + gender) case None => println("Gender: not specified") } |
Данный код можно переписать в более функциональном стиле исключив дублирование println используя выражение инициализации значения со сравнением по шаблону:
1 2 3 4 5 6 7 8 9 10 11 |
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[A] является контейнером для типа A, то вполне закономерно решить, что этот тип — это коллекция которая может быть пустой или состоящая из одного элемента заданного типа. И хотя тип Option не является коллекцией он содержит все метода работы с коллекциями как в классах типа List или Set более того, если Вам нужно можно трансформировать опциональный тип в List.
Выполнение каких-то действий с опциональными значениями
Допустим Вам необходимо выполнить какое-то побочное действие если присутствует значение, для этих целей вполне подойдет метод foreach:
serRepository.findById(2).foreach(user => println(user.firstName)) // prints “Johanna”
Функция передаваемая в foreach будет вызвана только раз если Option имеет значение Some или не будет выполнена вообще, если значение None.
Преобразование опционального типа
Также как и для списка у которого есть функция map выполняющая преобразования из List[A] в List[B] у опционального типа есть функция, выполняющая преобразования из Option[A] в Option[B]. Что означает, если у экземпляра Option[A] есть определённое значение Some[A] то результатом операции будет Some[B] или None в противном случае.
Если сравнить тип Option с типом List, то None – эквивалентно пустому списку, а преобразование пустого списка List[A] вернет пустой список List[B], по аналогии с этим Option[A] имеющий значение None преобразуется Option[B] который также имеет значение None. Пример использования:
val age = UserRepository.findById(1).map(_.age) // age is Some(32)
Операция flatMap
Рассмотрим пример с вложенным опциональным типом. (В нашем примере это поле gender):
val gender = UserRepository.findById(1).map(_.gender) // gender is an Option[Option[String]]
Возникает вопрос почему результатом работы с gender стал тип Option[Option[String]].
Это получается потому, что в результате работы есть контейнер Option содержит User в этом контейнере происходит и преобразование внутреннего поля который содержит Option[String] в свойстве gender.
Для того, что бы обойти такое преобразование используется метод flatMap. Этот метод позволяет выполнить преобразование List[List[A]] в List[B], аналогичное действие можно выполнить для Option[Option[A]]:
val gender1 = UserRepository.findById(1).flatMap(_.gender) // gender is Some(“male”)
val gender2 = UserRepository.findById(2).flatMap(_.gender) // gender is None
val gender3 = UserRepository.findById(3).flatMap(_.gender) // gender is None
Теперь результирующий тип Option[String]. Если у нас есть определенное поле gender по получим упрощенный Some или если поле не определено, – то None.
Что бы понять, как это работает рассмотрим, как происходит упрощение списка списков строк, при этом всегда стоит помнить, что поведение опционального типа аналогично списку. Например:
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”)
Если просто применить метод map к списку опциональных типов результат будет List[Option[String]]. Применение метода flatMap все элементы из внутренних коллекций помещаться в один единый плоский список. Каждый элемент Some[String] из оригинального списка извлекается из оболочки и помещается в результирующий список, все элементы значения None отбрасываються.
Фильтрация опциональных значений
К опциональному типу возможно применять операции фильтрации, как и к обычным спискам. Если экземпляр Option[A] определен, то это некоторый Some[A] который предаться в метод фильтр совместно с функцией предикатом которая возвращает true, тогда значение Some[A] возвращается иначе возвращается None ( в случае если предикат вернут false или значение Option[A] – None). Пример:
UserRepository.findById(1).filter(_.age > 30) // Some(user), because age is > 30
UserRepository.findById(2).filter(_.age > 30) // None, because age is <= 30
UserRepository.findById(3).filter(_.age > 30) // None, because user is already None
Применение опционального типа в цикле FOR(For comprehensions)
Тип Option может рассматриваться как коллекция и к нему применимы операции map , flatMap , filter и другие известные в коллекциях методы. Кроме того, этот тип может использоваться в цикле for.
Например, если мы просто хотим получить значение поля gender для простого экземпляра класса User, мы можем написать такой цикл for:
1 2 3 4 5 6 7 |
for { user <- UserRepository.findById(1) gender <- user.gender } yield gender // results in Some("male") |
Как Вы уже знаете из работы со списками этот код эквивалентен вызову flatMap. Если UserRepository вернет None или значение поля gender будет None то результат всего кода также будет None. Так как в примере пользователь определён и у него определено поле gеnder, то результатом будет Some.
Если мы например хотим вернуть значения поля gender для всех пользователей, то мы могли бы пройти итеративно по всем пользователям и для все их произвести вызов yield тем самым произведя значения взятые из поля, следующим способом:
1 2 3 4 5 6 7 |
for { user <- UserRepository.findAll gender <- user.gender } yield gender |
Таким образом мы произвели эффективное преобразование в плоский список (такой себе вариант упрощения) типа List[String] с содержимым List(“male”), потому, что свойство gender было определено только у первого пользователя.
Использование в левой части генератора
Шаблоны могут быть использованы в левой части генераторов внутри цикла for. При этом в этих выражениях можно использовать и опциональный тип. Перепишем код следующим образом:
1 2 3 4 5 |
for { User(_, _, _, _, Some(gender)) <- UserRepository.findAll } yield gender |
Использование Some в шаблоне в левой части генератора означает отбрасывания всех элементов из результирующей коллекции у которых анализируемое значение None.
Соединение опциональных типов в цепочки
Опции могут соединяться в цепочки по аналогии с частично применяемые функциями. Для этих целей вы вызываете метод orElse на экземпляре класса Option и передаете в качестве параметра другой экземпляр 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 метод.