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

в 15:48, , рубрики: inspections, scala, WAT, Блог компании Тинькофф Кредитные Системы, метки: , ,

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 или изменяемой переменной, не предлагая решений. Надеюсь, теперь они у вас есть.

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

Автор: vuspenskiy

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js