company_banner

Scala WAT: Обработка опциональных значений

    В сети и на Хабре уже довольно много статей вводного уровня про то, как начать писать на Scala, и раскрывающих особенности функционального подхода.

    Какое-то время назад мы полностью перевели на Scala один из основных для веба проектов. За это время я наблюдал эволюцию разработчиков, включая свою собственную, и у меня скопился объёмный список конструкций, которые тянет написать, если вы раньше писали на Java, и для которых правильное решение на Scala может не быть сходу очевидным. Данные рекомендации могут быть не очень понятны тем, кто до сих пор пишет на Java и не видел до этого код на Scala. Я не буду разъяснять работу стандартных функций и функциональных концепций, всё ищется по ключевым словам в сети.

    Начнём с тривиального случая: используемое вами Scala API возвращает Option. Вы хотите получить значение из него и обработать. Программисты на Java написали бы это так:

    val optionalValue = service.readValue()
    if(optionalValue.isDefined) { // ещё любят писать optionalValue != None
    	val value = optionalValue.get
    	processValue(value)
    } else {
    	throw new IllegalArgumentException("No value!")
    }
    

    Что плохо в этом коде? Для мира Scala тут несколько неприемлемых особенностей: во-первых, optionalValue, в коде на Scala очень много интерфейсов возвращает Option, и это прекрасно потому, что требует писать обработку ошибок, а не забивать на неё, надеясь, что ошибка поймается в общем обработчике ошибок (который выдаст что-то невразумительное, типа, «Неизвестная ошибка, повторите позже»). Может быть, вы очень ответственны и думаете: на Java я обрабатывал все ошибки! Может быть, но опыт показал, что, переписывая большой класс на Scala, несмотря на множество всевозможных проверок, стабильно находишь пару мест, где ошибка не обрабатывалась и приходится находить способы это сделать, потому что писать код, явно кидающий NPE не позволяет совесть. Короче, добавляя префикс optional вы будете часто получать двойников переменных, в которых не будет особого смысла. Второе — проверка на пустоту Option в явном виде, как будет показано ниже, слишком брутальна. И, в-третьих, вызов Option.get, который вообще надо было бы запретить (всегда, когда его вижу, значит, что код можно переписать намного чище). По факту, ничего с точки зрения системы типов не защищает такой код. Проверяющий if кто-то может переписать или забыть и тогда вы получите аналог NPE, что полностью обесценивает использование класса Option.

    На самом деле варианта написать этот код красивее два. Первый происходит, когда в случае, если у вас есть значение, то нужно сделать дополнительные действия, а отсутствие значения обрабатывать не требуется. Тогда, пользуясь тем, что Option — Iterable, можно написать так:

    for(value <- service.readValue()) { 
    	processValue(value)
    }
    

    Второй — когда нужно обработать оба случая. Тогда рекомендуется использовать pattern matching:

    service.readValue() match {
    	case Some(value) => processValue(value)
    	case None => throw new IllegalArgumentException("No value!")
    }
    

    Обратите внимание, что каждый из вариантов лишён описанных недостатков.

    Продолжим. Часто получение значения связанно с обработкой исключений, при этом зачастую рождаются такие конструкции:

    var value: Type = null
    try {
    	value = parse(receiveValue())
    } catch {
    	case e: SomeException => value = defaultValue
    }
    

    Здесь тоже есть сразу несколько недостатков: мы используем изменяемые переменные, явно указываем тип, хотя он, более менее, очевиден и используем null, который в хорошей scala-программе не очень-то нужен и несёт одни неприятности. Пользуясь тем, что все выражения в Scala возвращают значения можно записать пример выше так:

    val value = 
    	try {
    		parse(receiveValue())
    	} catch {
    		case e: SomeException => defaultValue
    	}
    

    Код становится почище и мы избавляемся от изменяемости. Иногда задумка автора начального кода бывает даже интереснее: он уже познакомился с Option и знает, что это хорошо, и, особенно, чувствует, что здесь они нужны:

    var value: Option[Type] = null
    try {
    	value = Some(parse(receiveValue()))
    } catch {
    	case e: SomeException => value = None
    }
    

    Кстати, тут есть интересная особенность: если parse, вдруг, не дай Б-г, вернёт null, что может статься, то мы получим Some(null), а не None, чего можно было бы ожидать, поэтому, как минимум, надо было бы написать Option(parse(receiveValue())), а ещё лучше использовать стандартный пакет scala.util.control.Exception._ так:

    val value = catching(classOf[SomeException]).opt({ parse(receiveValue()) }).getOrElse(defaultValue)
    

    Хорошо. А как быть, если мы имеем список опций, где часть элементов имеют значение, а часть нет, а нам надо получить список заполненных значений, чтобы поработать с ними. Разработчик, поднаторевший в стандартной библиотеке Scala сразу вспомнить про метод filter, который создаёт коллекцию из элементов существующей, удовлетворяющих предикату, может даже вспомнит про filterNot, и напишет:

    list.map(_.optionalField).filterNot(_ == None).map(_.get)
    

    Как было описано выше, это выражение порочно, но что делать с ним сходу непонятно. Подумав какое-то время, можно прийти к выводу, что очень хочется, на самом деле сделать flatten, но List и Option — это разные монады, которые ещё и не коммутируют! И вот тут спасает то, что Scala не только функциональный язык, но и объектно-ориентированный, ведь и List и Option на самом деле — Iterable, где map и flatten определёны, бинго! Компиллятор Scala умеет выводить тип правильно и мы пишем:

    list.map(_.optionalField).flatten
    

    Что спокойно можно сократить до:

    list.flatMap(_.optionalField)
    

    Вот это уже здорово!

    Напоследок простой пример из Twitter «Effective Scala», для того же списка опций. Этот пример — одно из моих последних открытий. К сожалению, он редко применим к коду нашего проекта, но всё же его красота подкупает. Итак, мы имеем список опций и хотим преобразовать его, выполнив для существующих значений один код, а для несуществующих — другой. В принципе, в лоб мы пишем:

    iterable.map(value => value match {
        case Some(value) => whenValue(value)
        case None => whenNothing()
    })
    

    Это довольно чисто, но, благодаря тому, что метод map принимает функцию и способу определения Partial Functions в Scala мы, можем написать ещё элегантнее:

    iterable.map({
        case Some(value) => whenValue(value)
        case None => whenNothing()
    })
    

    Кстати, с передачей функций в map связанна ещё одна особенность. Иногда можно увидеть код:

    iterable.map(function(_))
    

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

    iterable.map(function)
    

    Ну вот и всё на этот раз. Надеюсь, примеры выше помогут улучшить вашу базу кода на Scala. Очень жалко, что по приведённым примерам плагины к IntelliJ IDEA и Maven, проверяющие качество кода на Scala, не умеют подсказывать, что хорошо, а что плохо, констатируя только наличие в коде null или изменяемой переменной, не предлагая решений. Надеюсь, теперь они у вас есть.

    В следующий раз хочется рассказать про использование стандартных коллекций. А ваши личные рецепты сделать код лучше, было бы интересно узнать из комментариев.
    TINKOFF
    IT’s Tinkoff — просто о сложном

    Comments 23

      +5
      <зануда>Мелкое замечание: Option не является наследником Iterable, есть неявная конверсия Option в Iterable.</зануда>
        +1
        Вообще вы все правильно написали. Добавлю только про пример с try catch. C 2.10 удобно искользовать Try. Для тех кому приходится использовать Lift там есть Box Failure и helper tryo.
          0
          Еще очень часто видел в рассылках что люди не знают про Collection view и List headOption.
            0
            Мы пока на 2.9.1 сидим. Хочется подождать появления минорных версий 2.10, чтобы не было, как с 2.9.0.1 :) Хотя, отзывы про миграцию на 2.10, конечно, очень обнадёживают.
            0
            наверное, код на яве должен был быть такой:

            val optionalValue = service.readValue()
            if(optionalValue.isDefined) { // ещё любят писать optionalValue != None
                processValue(optionalValue)
            } else {
                throw new IllegalArgumentException("No value!")
            }
            
              0
              Скорее всего предполагается, что processValue принимает T, а не Option[T].
                0
                Обычно да. Потому что, как раз задача приведённого кода обработать отсутствие значения. Что он и делает, как умеет.
              +1
              В простых случаях, когда не нужна сложная валидация с сообщениями на каждую ошибку на Option и for можно городить вот такие чудные конструкции (вроде бы и без комментариев должно быть читаемо):

              case class User(login: String, password: String, blocked: Boolean)
              def getParam(name: String): Option[String]
              def isSecure(password: String): Boolean
              def checkUser(login: String, password: String): Option[User]
              def changePassword(user: User, newPassword: String)
              
              for {
              	login <- getParam("login") if !login.isEmpty
              	password <- getParam("password") if isSecure(password)
              	newPassword <- getParam("new_password") 
              	confirmPassword <- getParam("confirm_password") if confirmPassword == newPassword
              	user <- checkUser(login, password) if !user.blocked
              } changePassword(user, newPassword)
              


              В более сложных случаях уже надо на Either переходить или что-то подобное.
              з.ы. в примере с Partial Functions достаточно фигурных скобок:
              iterable.map {
                  case Some(value) => whenValue(value)
                  case None => whenNothing()
              }
              
                +1
                Для более сложных случаев можно использовать Validation из scalaz.
                  0
                  Угу, я его и имел в виду, просто не стал упоминать то, что сам не пробовал
                0
                Еще можно пользуясь неявной конвертацией Option в Iterable делать вот так (не все правда это одобряют):
                Some(1) ++ None ++ Some(2) ++ None ++ Some(3) // == Seq(1, 2, 3)
                

                ++ это метод для сложения 2-х коллекций
                  +1
                  В общем, мы не знаем столько будет Option'ов. Хотя можно, конечно, зарубить, foldLeft =)
                    0
                    это для случая когда у нас известное заранее число опциональных параметров (во всяких билдерах например)
                • UFO just landed and posted this here
                • UFO just landed and posted this here
                    0
                    Как бы, в вашем примере Either, как будто бы для того, чтобы вставить Either, ведь мы ошибку обрабатываем. Хотя код получается довольно страшный. Честно говоря, все примеры с Either, которые я видел, довольно чудовищны и хороший пример, который я могу предположить это только, если значения из неудачной рутины нужно накапливать по мере обработки. Например, отладочное сообщение, которое по мере поднятия по стеку обрастает подробностями. Это функциональный подход, когда исключений вообще нет. А в вашем случае используются все возможные механизмы сразу: исключения, Option, Either, в итоге сложность получившегося кода говорит сама за себя. Надо выбрать что-то одно.

                    Вообще, я в начале писал, что все рекомендации исключительно на основе реального кода и примеров его упрощения. У нас много ситуаций, когда есть fallback'и на местах. Так код получается более robust, а не говорит сразу: ну я ничего не знаю, всё пропало.
                    • UFO just landed and posted this here
                        0
                        Вопрос, кто определял serialize. Если это внешнее API, да надо это обрабатывать и тут уже ничего не выдумаешь. Если ваш, то подумайте о том, что я написал, в плане выбора исключений или Either.
                          0
                          Этот пример очень напоминает Akka 2 0 4 SerializationExtension.
                          • UFO just landed and posted this here
                    0
                    Истинные любители однострочников записывают такое выражение:

                    service.readValue() match {
                        case Some(value) => processValue(value)
                        case None => throw new IllegalArgumentException("No value!")
                    }
                    


                    вот так:

                    processValue service.readValue.getOrElse{ throw new IllegalArgumentException("No value!") }

                    Only users with full accounts can post comments. Log in, please.