Часть 1. Функциональная
Эта статья (если быть до конца честным — набор заметок) посвящена ошибкам, которые совершают новички, ступая на путь Scala: не только джуниоры, но и умудренные опытом программисты с сединами в бороде. Многие из них до этого всегда работали лишь с императивными языками такими как C, C++ или Java, поэтому идиомы Scala оказываются для них непонятными и, более того, неочевидными. Поэтому я взял на себя смелость предостеречь новообращённых и рассказать им об их типичных ошибках — как совсем невинных, так и тех, что в мире Scala караются смертью.
Структура публикации:
Вместо вступления
В самом начале своей карьеры я попал в очень интересную ситуацию: мне, тогда еще совсем юному разработчику, пришлось объяснять скалические идиомы своим старшим коллегам. Так получилось — и я благодарен жизни за этот во многом бесценный опыт. Сейчас я помогаю в освоении Scala разработчикам всех уровней от мала до велика, так как в компании где я работаю, существует внутренняя система обучения сотрудников. В данный момент я вместе с другими менторами занимаюсь проверкой и поддержкой курсов по Scala.
Изначально планировалось написать эту статью на английском под звучным заглавием: «Scala for juniors and junior seniors». Но работать с русским текстом оказалось намного быстрее и удобнее, поэтому пришлось пожертвовать непереводимой игрой слов в названии. Просто имейте в виду, что статья рассчитана не только на чистых джуниоров, но и вообще на всех, кто начинает свое знакомство с языком Scala, какой бы большой опыт императивного программирования у них ни был.
Эта статья, по большому счету, представляет собой солянку из практических советов, в виду чего она лишена какой-либо сложной многоуровневой академичной структуры подачи материала. Вместо этого статья разбита на две части: в первой мы поговорим об идиомах функционального программирования в Scala, а во второй обсудим объектно-ориентированные идиомы. И начнем мы с наиболее недооцененной возможности Scala — псевдонимов типов (type aliases).
О псевдонимах типов
Многим начинающим разработчикам, доселе не имевшим опыта с typedef
, эта возможность языка покажется бесполезной. Однако, это не совсем так: в C псевдонимы типов используются стандартной библиотекой на каждом шагу и являются одним из средств обеспечения переносимости кода между платформами; кроме того, они заметно улучшают читаемость кода, в котором замешаны указатели большой степени косвенности. Известный всем пример — каноничное объявление функции signal
совершенно нечитаемо:
void (*signal(int sig, void (*func)(int)))(int);
Однако добавление псевдонима для указателей на функцию-обработчик сигнала решает эту проблему:
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
В C++ без псевдонимов типов немыслимо статическое метапрограммирование, вдобавок, они позволяют не сойти с ума от полных имен шаблонных типов.
В таких языках, как Ocaml и Haskell, имеется ключевое слово type
, семантика которого намного сложнее чем в Scala. Например, ключевое слово type
позволяет создавать не только синонимы типов, но и алгебраические типы данных:
(* Пример на Ocaml*)
type suit = Club | Diamond | Heart | Spade;;
И этим дело не ограничивается: в Ocaml и SML вы также можете создавать
union-типы (типы-объединения):
(* OCaml *)
type intorstring = Int of int | String of string;;
(* SML *)
datatype intorreal = INT of int | REAL of real
На данный момент (Scala 2.12) так не умеет: функциональность, объявляемая при помощи ключевого слова type
ограничивается синонимизацией типов, а также объявлением path-dependent типов. Однако в последующих версиях эту возможность планируется добавить (спасибо senia за уточнение). Для чего вообще стоит давать уже известным типам другие имена? Во-первых, это добавляет дополнительную семантическую нагрузку, и, например, смысл типа DateString
, становится более понятным, чем просто String
, а Map[Username, Key]
выглядит лучше, чем Map[String, String]
. Во-вторых, синонимизация позволяет сократить большие и сложные сигнатуры типов: Map[Username, Key]
выглядит неплохо, однако Keystore
намного короче, и понятней.
Конечно же, у типов-синонимов есть и свои недостатки: Вы видите тип Person
, и не можете понять, объект это, класс или псевдоним.
Злоупотреблять данным средством определенно не стоит, однако есть ряд ситуаций, когда они действительно будут полезными:
- у вас есть функция, которая может быть связана с определенным действием, которое вполне можно проименовать и не пугать читателя сигнатурами вроде
() => Unit
, связав этот тип функций с именемAction
. - вы абстрагируетесь над
Коллекцией[Коллекций, СКоллекциями[Упс]]
- вы добавляете дополнительную семантическую информацию к существующему типу
Больше примеров вы можете найти здесь, просто промотайте чуть ниже до раздела с примерами.
Еще раз о присваивании
Присваивание в Scala — это не совсем то, к чему вы привыкли. Давайте рассмотрим эту операцию и увидим, что она не так проста, как может показаться на первый взгляд:
// это было простым связыванием, ничто не предвещало беды
scala> val address = ("localhost", 80)
address: (String, Int) = (localhost,80)
scala> val (host, port) = address
host: String = localhost
port: Int = 80
Мы только что разобрали кортеж на две переменные, однако кортежами дело не ограничивается:
scala> val first::rest = List(1,2,3,4,5)
first: Int = 1
rest: List[Int] = List(2, 3, 4, 5)
Аналогичные операции мы можем провести и с case class
:
case class Person(name: String, age: Int)
val max = Person("Max", 36)
// max: Person = Person(Max,36)
val Person(n, a) = max
// n: String = Max
// a: Int = 36
Более того:
scala> val p @ Person(n, a) = max
// p: Person = Person(Max,36)
// n: String = Max
// a: Int = 36
В последнем случае по имени p
мы получим саму запись case class
, а по имени n
получим имя, по a
— возраст.
Искушенный читатель уже заметил, что присваивание ведет себя точно также, как и сопоставление с образцом. Подобная функциональность реализована и в других языках, например, Python и Erlang. Используйте эту функциональность в первую очередь для распаковки структур данных. Но не злоупотребляйте: распаковка сложных структур данных очень сильно ухудшает читаемость.
Options
Многие из вас уже знакомы с типом Optional
в Java 8. В Scala тип Option
выполняет те же функции. А многим адептам Java этот тип может быть известным по гугловской библиотеке Guava.
Да, Optional
используется для избегания null
, а в последствии и NullPointerException
. Да, у него есть методы isEmpty
и nonEmpty
. В варианте Guava есть метод isPresent
. И многие, кто использовал или не использовал Optional
в Java или других языках, неправильно использует его в Scala.
Однако не все в курсе что в той же Guava уOptional
-ов определен методtransform
, который ведет себя аналогично скаловскомуmap
.
Неправильное использование Option
— распространенная проблема. Option
, в первую очередь, нужен, чтобы концептуально показать вероятно отсутствующую сущность, а не убегать от NPE. Да, проблема есть, и проблема серьезная. Кто-то для этого даже язык свой изобретает. Но давайте вернемся к неправильному использованию Option
в Scala:
if (option.isEmpty)
default
else
// может взорватся c NoSuchElementException (без проверки)
option.get
Мы делаем проверку, и у нас, вроде бы, ничто не должно взорваться. Поверьте, в промышленном коде можно ошибиться, и в условии может оказаться совсем не то выражение, которое ожидалось. И даже тесты могут быть написаны неправильно. Не вами, так вашими предшественниками.
Вообще, в примере выше есть еще одна проблема. Ваш flow зависит от какого-то Boolean, и целостность его нарушается.
Некоторые разработчики имеют свойство подгонять тесты под уже «работающий» код. Правильнее и короче вышеприведенный код можно записать так:
option getOrElse default
Чем компактнее ваш код, тем легче найти в нем ошибку, и тем сложнее эту ошибку допустить. Существует полезный метод orElse
, который позволяет сцеплять различные Option
.
Зачастую вам нужно трансформировать значение внутри Option, если оно вообще там имеется. Для этого существует метод map
: он вынимает значение, преобразовывает его и упаковывает обратно в контейнер.
val messageOpt = Some("Hello")
val updMessageOpt = messageOpt.map(msg => s"$mgs cruel world!")
updMessageOpt: Option[String]
А иногда бывает и так:
val messageOptOpt = Some(Some("Hello"))
Option
могут безмерно вкладываться друг в друга. Эту проблему решает метод flatMap
или метод flatten
. Первый работает аналогично map
— он трансформирует внутреннее значение, однако при этом уплощает структуру, второй просто упрощает структуру.
Представим, что у нас есть некая функция которая возвращает Option
:
def concatOpt (s0: String, s1: String) = Option(s0 + s1)
тогда мы можем получить подобный результат:
messageOpt.map(msg => concatOpt(msg, " cruel world"))
res0: Option[Option[String]] = Some(Some(Hello cruel world))
// а так работает `flatMap`:
messageOpt.flatMap(msg => concatOpt(msg, " cruel world"))
res6: Option[String] = Some(Hello cruel world)
// А так flatten
messageOptOpt.flatten == Some("Hello")
res1: Option[String] = Some(Hello)
В Scala существует еще один механизм, способный заметно облегчить работу с Option
, и он, возможно, известен вам под именем "For comprehension".
val ox = Some(1)
val oy = Some(2)
val oz = Some(3)
for { x <- ox; y <- oy; z <- oz }
yield x + y + z
// res0: Option[Int] = 6
Если какой-то из Option
-типов будет равен None
— после yield
пользователь получит пустой контейнер, вернее, значение пустого контейнера. В случае с Option
это None
. В случае со списком — Nil
.
И главное, старайтесь сделать все, лишь бы не вызывать метод get
. Это ведет к потенциальным проблемам.
Я знаю, что вы молодец и все проверили. Уверен, ваша мама тоже так думает, но это не дает вам повода лишний раз дергать get
.
Списки
У Option
есть get
, у списка есть head
, а еще у него есть init
и tail
. Вот что мы можете получить, вызывая вышеописанные методы у пустого списка:
// Для пустого списка:
init: java.lang.UnsupportedOperationException
head: java.lang.NoSuchElementException
last: java.lang.NoSuchElementException
tail: java.lang.UnsupportedOperationException
Конечно, с вами этого никогда не случится, если вы проверяете лист на пустоту.
Начинающий скалист будет делать это, используя на своем пути пресловутую конструкциюif
-else
.
Вызов list.head
и сотоварищей — один из самых лучших способов лишить себя
сна при работе со списками.
Извивайтесь гремучей змеей, делайте все возможное чтобы не использовать list.head
и его друзей.
Вместо head
неплохим вариантом будет использование метода headOption
. Метод lastOption
ведет себя аналогично. Если вы каким-либо образом привязаны к индексам, можете воспользоваться методом isDefinedAt
, который принимает целочисленный аргумент (индекс) в качестве параметра. Все описанное выше по-прежнему подразумевает проверки, о которых можно забыть. Найдется еще тысяча и одна причина чтобы вы их опустили сознательно. Правильной и идиоматичной альтернативой будет использование сопоставления с образцом. Тот факт, что список является алгебраическим типом, не даст вам забыть о Nil
, вы сможете спокойно избежать вызовов head
и tail
, сэкономив несколько строчек кода:
def printRec(list: List[String]): Unit = list match {
// вы также можете сопоставить одноэлеметный список, как и список из
// n, и k элементов, если захотите. That's the power!
case Nil => ()
case x::xs => println(x)
printRec(xs)
}
Немного о производительности
С точки зрения производительности для односвязного списка, коим является скаловскийList
(он жеscala.collection.immutable.List
), наиболее дешевой операцией будет запись в начало списка, нежели в конец. Для записи в конец списка требуется пройти весь список до конца. Сложность записи в начало списка O(1), в конец O(n). Не забывайте об этом.
Option[List[?]]
В коде только что познакомившихся со Scala с завидной периодичностью встречаются Option[List[A]]
. Как в аргументах функции, так и в качестве возвращаемого типа. Зачастую, сотворившие подобный шедевр используют следующую аргументацию: «Так у нас список может быть, а может и не быть, что же я буду вместо него null
использовать?».
Хорошо, давайте представим другую ситуацию: Option
представляет концептуально возможный неудачный исход, а список — набор возвращаемых данных. Представим себе, что у нас есть некий сервер, который возвращает Option[List[Message]]
. Если все хорошо — получаем список сообщений внутри Some
. Если сообщений нет — получаем пустой список внутри Some
. А если на сервере произошла ошибка, получаем None
. Разумно и жизнеспособно?
А вот и нет! Если у нас в системе может возникнуть ошибка, нам определенно нужно знать какая. А для этого нам надо вернуть или Throwable
или некий код. Позволяет ли Option
нам это сделать? Не очень, однако Try
и Either
могут вам в этом помочь.
Список может быть пустым так же, как и Option
, поэтому можно спокойно передавать пустой список, если что-то пойдет не так. Я пока еще не видел контр-примеров, когда конструкция Option[List]
могла бы быть жизнеспособной. Буду очень рад, если у вас такие примеры найдутся, и вы ими со мной поделитесь.
Option[A] => Option[B]
Совсем недавно я наткнулся на еще одно интересное применение Option
. Давайте рассмотрим сигнатуру следующей функции:
def process (item: Option[Item]): Option[UpdatedItem] = ???
Нет нужды усложнять преобразование использованием дополнительного контейнера: это делает функцию менее общей и визуально захламляет сигнатуру функции. Вместо этого следует использовать функцию типа A => B
. А если вы хотите сохранить тип исходного контейнера, оберните исходный результат в этот контейнер и используйте функции map
или flatmap
для последующей трансформации данных.
Кортежи
Наличие кортежей (tuples) — интересная особенность ряда функциональных (и не только) языков. В функциональных языках кортежи возможно использовать аналогично записям (records). Описываем кортеж с нужными данными и оборачиваем в новый тип, например, используя newtype
в Haskell, в результате получая новый тип, об имплементации которого пользователю ничего не известно. В чисто функциональных языках без кортежей никуда: они позволяют замечательно представлять словари (dictionaries). Конволюция без них была бы менее наглядной.
В некоторых языках, например, Erlang, записи появились гораздо позже кортежей. Более того, записи (records) в Erlang так же являются кортежами.
Scala — язык объектно-ориентированный. Да, с поддержкой элементов функционального программирования. Уверен, что многие со мной не согласятся, но давайте не будем забывать, что в Scala все есть объект. Наличие case
-классов во многом снижает необходимость кортежей: мы получаем неизменяемые записи, которые тоже можно сопоставлять с паттернами (об этом расскажем далее). С каждым case
-классом уже связан свой тип.
Кортежи зачастую приходится использовать и пришедшим из объектно-ориентированных языков: им эти языковые средства в диковинку. Начнем с того, что их не именуют.
Если кортеж не используется как анонимная помойка, его следует именовать.
Если у вас есть желание использовать кортеж для хранения данных, используйте для этогоcase class
.
Для функционального стиля хорошим тоном считается использование упомянутых ранее псевдонимов для типов (type aliasing):
type Point = (Double, Double)
В будущем вы ссылаетесь на вполне себе именованные типы, и у вас не будет таких страшных вещей:
// плохо
def drawLine(x: (Double, Double), y: (Double, Double)): Line = ???
// не плохо
def drawLine(x: Point, y: Point): Line = ???
В Scala достучаться до элемента кортежа можно и по индексу. Например:
// Плохо!
val y = point._2 // второй элемент
Особенно печально это выглядит при работе с коллекциями:
// Печально!
points foreach { point: Point =>
println(s"x: ${point._1}, y: ${point._2}")
}
И так делать не надо. Конечно, есть исключительные случаи, когда подобного рода меры повышают читаемость:
// Оправданно
rows.groupBy(_._2)
Но в большинстве случаев синтаксис с подчеркиванием лучше не использовать. Про него вообще лучше забыть и не вспоминать. В Scala существуют более естественные способы обходиться без подобного синтаксиса.
В Scala всегда можно обойтись без pair._2
. И это нужно делать.
Чтобы понять и разобраться, почему все именно так, давайте обратимся к функциональным языкам.
Вопрос: Уважаемая редакция, почему индексы списков в Scala начинаются с нуля, а кортежей — с единицы? Василий, Похабинск.
Ответ: Здравствуйте, Василий. Ответ простой: потому что так исторически сложилось. В SML для доступа к элементам кортежа существуют функции #1
и #2
. В Haskell
существуют всего две функции для доступа к элементам кортежа: fst
и snd
.
-- Как-то так. В Haskell аргументы функции идут сразу же после имени
-- этой самой функции. Без скобок.
fst tuple
А вот получить третий или пятый элемент кортежа просто так уже не получится. Не верите? А зря. И не поверите, если я вам скажу, что сопоставление с образцом — наиболее естественный. И не только в Haskell
.
Ocaml
let third (_, _, elem) = elem
Erlang
1> Tuple = {1,3,4}.
{1,3,4}
2> Third = fun ({_Fst, _Snd, Thrd}) -> Thrd end.
#Fun<erl_eval.6.50752066>
3> Third(Tuple).
4
Python
А вот вам пример не из функционального языка:
>> (ip, hostname) = ("127.0.0.1", "localhost")
>>> ip
'127.0.0.1'
>>> hostname
'localhost'
>>>
А теперь давайте применим полученные знания к Scala
// предположим, у нас есть прямоугольник
trait Rectangle {
def topLeft: Point
...
}
// сопоставления с образцом при связывании
val (x0, y0) = rectangle.topLeft
// сопоставление с образцом внутри лямбды:
points foreach { case (x, y) =>
println(s"x: ${x}, y: ${y}")
}
Стандартный механизм сопоставления, использующий ключевое слово match
, тоже никто не отменял.
Также кортежи можно использовать как анонимные помойки, и это порой оправданно. Дело в том, что во многих функциональных языках существует сопоставление с образцом на уровне сигнатур функций:
-- немного кода на haskell
-- здесь описываются типы:
map :: (a -> b) -> [a] -> [b]
-- а вот тут сопоставление с образцом на уровне
-- сигнатуры функции
-- если нашим аргументом является
-- пустой список:
map _ [] = []
-- более идиоматично было бы использование x:xs но, считаю, для не
-- знающих Haskell head:tail будет нагляднее. : - оператор именуемый cons
-- является эквивалентным скаловскому ::
map fun (head:tail) = fun head : map fun tail
Схожий механизм находит применение в SML и Erlang. К сожалению, Scala такой возможности лишена. Поэтому кортежи можно использовать для группировки и последующего сопоставления с образцом:
// похоже на Haskell, но не то :(
def map [A, B] (f: A => B, l: List[A]): List[B] = (f, l) match {
case (f, Nil) => List.empty
case (f, head::tail) => f(head) :: map(f, tail)
}
Зачастую бывает необходимо обновить значение в каком-то из элементов кортежа. Для этого подойдет метод copy
.
val dog = ("Rex", 13)
val olderDog = tuple.copy(_2 = 14)
Об использовании кортежей в Haskell и SML вы можете прочитать если перейдете по ссылкам.
В реальности использование кортежей не есть лучший способ представления координат (во всяком случае в Scala). В Scala для этого лучше использовать case class
-ы. Потребность в кортежах в основном продиктована наличием универсальных библиотек для случаев, когда требуется свернуть составную запись в обобщенном виде. Например zip
или groupBy
. Так что, если вы хотите использовать кортежи, используйте их только в случае написания обобщенных алгоритмов. Во всех остальных случаях лучше иметь старый добрый case class
.
О нижнем подчеркивании
Можете ли вы перечислить все случаи, когда в Scala используется _
? По результатам опроса сделать это могут всего 7% Scala-разработчиков. Нижнее подчеркивание используется в языке многократно и в различных контекстах. Здесь это хорошо проиллюстрировано. В большинстве случаев без нижнего подчеркивания обойтись не получится: синтаксис требует их для множественных импортов или импортов с исключениями. Есть и другие обоснованные применения (даже там, где без них можно обойтись). Однако, внутри лямбда-выражений читаемости они не добавляют. Сложности в чтении лямбд возникают при количестве аргументов-подчеркиваний, превышающем 2.
При сопоставлении с образцом они также способны испортить вам жизнь. Понятно, что из себя представляет Fork
и Leaf
?
def weight(tree: CodeTree): Int = tree match {
case Fork(_, _, _, weight) => weight
case Leaf(_, weight) => weight
}
А так?
def weight(tree: CodeTree): Int = tree match {
case Fork(left, right, chars, weight) => weight
case Leaf(char, weight) => weight
}
Как вы уже могли заметить — эти значения не используются. Но это не тот случай, когда их надо затирать нижним подчеркиванием. Поверьте, скорость программирования не упирается в скорость набора текста: можно написать несколько лишних символов в угоду читаемости.
В заключение
В этой статье я попытался (а уж получилось ли, не мне судить) рассказать вам об основных функциональных идиомах Scala и провести ряд параллелей с другими функциональными языками. В следующей части я расскажу про идиомы связанные с ООП и коллекциями, а также изложу свои мысли касательно инфраструктурных вопросов, терзающих многих начинающих разработчиков. Очень надеюсь, что эта статья вам понравилась. Спасибо за то, что набрались терпения и дочитали ее до конца. Продолжение следует.