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