Pull to refresh

Советы начинающему скалисту

Reading time13 min
Views28K

Часть 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 и провести ряд параллелей с другими функциональными языками. В следующей части я расскажу про идиомы связанные с ООП и коллекциями, а также изложу свои мысли касательно инфраструктурных вопросов, терзающих многих начинающих разработчиков. Очень надеюсь, что эта статья вам понравилась. Спасибо за то, что набрались терпения и дочитали ее до конца. Продолжение следует.

Tags:
Hubs:
Total votes 36: ↑33 and ↓3+30
Comments20

Articles