Более 50 тысяч человек записались на курс Мартина Одерски "Принципы функционального программирования в Scala", что проходит на Coursera. Это огромное число программистов, многие из которых, возможно, впервые познакомились с языком Scala и функциональным программированием.
Вполне возможно, вы — один из них, или ваше знакомство со Scala началось по-другому. Так или иначе, взявшись за Scalа, Вам бы хотелось углубиться в этот прекрасный язык, но он всё ещё кажется экзотичным и непонятным. В таком случае эта серия статей как раз для Вас.
Несмотря на то, что курс на Coursera освятил Scala достаточно полно, отпущенные временные рамки не позволили авторам курса объяснить все тонкости языка. Поэтому новичку некоторые возможности могут показаться волшебными заклинаниями. Вы можете ими пользоваться, но без понимания того, как они устроены и почему они устроены именно так.
В этой статье и в тех, что скоро последуют за ней, мне бы хотелось прояснить ситуацию, избавив вас от пробелов в понимании Scala. Также я расскажу о тех особенностях языка, которые доставили мне немало трудностей, когда я только начинал знакомиться со Scala. Я не мог найти хороших источников информации, поэтому мне приходилось пробираться на ощупь. Где это будет уместно, я попытаюсь показать примеры идиоматичного TM применения Scala.
Но для вступления — достаточно. При чтении помните: несмотря на то, что от вас не требуется знание материала упомянутого курса Coursera, иногда я буду ссылаться на курс и было бы здорово, если бы вы имели представление об основах языка.
Примечание переводчика: Стоит указать русскоязычные материалы по Scala, из которых можно узнать об основах языка. Для этого подойдёт Scala Школа! от Twitter
В Coursera вы познакомились с очень мощной возможностью языка: сопоставление с образцом (pattern matching). С её помощью можно проводить разбор структуры данных на составляющие, связывая значения, из которых она состоит, с переменными. Сопоставление с образцом есть не только в Scala, оно играет важную роль в таких языках как Haskell и Erlang.
Из курса лекций вы узнали как проводить разбор различных структур данных: списков, потоков
и других экземпляров case
-классов. Но является ли этот набор фиксированным, зашит ли он
в язык или вы можете расширять его? И как этот механизм устроен? Есть ли какая то магия, что
позволяет нам писать такой код?
case class User(firstName: String, lastName: String, score: Int)
def advance(xs: List[User]) = xs match {
case User(_, _, score1) :: User(_, _, score2) :: _ => score1 - score2
case _ => 0
}
Оказывается, совсем нет. Мы можем писать такой код благодаря так называемым экстракторам (extractor).
В наиболее общем виде экстрактор выполняет функции, противоположные конструктору. Конструктор позволяет создавать новые объекты на основе списка параметров, а экстрактор извлекает список параметров, с помощью которого объект был построен.
В стандартной библиотеке Scala есть набор предопределённых конструкторов, с некоторыми
мы скоро познакомимся. Для сase
-классов Scala автоматически создаёт объект-компаньон
(companion object): синглтон, который содержит не только метод-конструктор apply
, но и
метод-экстрактор unapply
— именно с помощью этого метода происходит разбор значения
при сопоставлении с образцом.
Существует множество вариантов сигнатур типов для метода unapply
. Мы начнём с наиболее
распространённого. Давайте представим, что наш класс User
совсем не case
-класс, а
трэйт (trait) с двумя классами, наследующими от него. Пока он содержит всего одно поле:
trait User {
def name: String
}
class FreeUser(val name: String) extends User
class PremiumUser(val name: String) extends User
Мы хотим определить экстракторы для классов FreeUser
и PremiumUser
в соответствующих объектах-компаньонах,
в точности так как это сделал бы за нас компилятор Scala, если бы мы определили наши классы с помощью case
.
Если экстрактор извлекает всего одно значение, сигнатура метода unapply
выглядит так:
def unapply(object: S): Option[T]
Метод принимает объект типа S
и возвращает Option
от типа T
, этот тип является параметром, который мы хотим извлечь.
Запомните, что тип Option
в Scala — это безопасная альтернатива нулевым указателям (null
). Мы посвятим
этому типу отдельную главу, но пока условимся, что наш метод unapply
должен вернуть либо значение Some[T]
(если нам удалось извлечь значение) или None
(это означает, что извлечь параметр нельзя), согласно реализации
метода.
Вот и наши экстракторы:
trait User {
def name: String
}
class FreeUser(val name: String) extends User
class PremiumUser(val name: String) extends User
object FreeUser {
def unapply(user: FreeUser): Option[String] = Some(user.name)
}
object PremiumUser {
def unapply(user: PremiumUser): Option[String] = Some(user.name)
}
Теперь попробуем в интерпретаторе:
scala> FreeUser.unapply(new FreeUser("Daniel"))
res0: Option[String] = Some(Daniel)
Обычно мы не пользуемся этим методом напрямую. Он вызывается, когда экстрактор используется при сопоставлении с образцом.
Если метод unapply
вернул Some[T]
, это означает, что сопоставление с образцом произошло успешно,
извлечённое значение связывается с переменной, объявленной в образце. В случае None
сопоставление
с образцом произошло безуспешно и мы переходим к следующему case
-выражению.
Давайте воспользуемся нашими экстракторами в сопоставлении с образцом:
val user: User = new PremiumUser("Daniel")
user match {
case FreeUser(name) => "Hello " + name
case PremiumUser(name) => "Welcome back, dear " + name
}
Вероятно, Вы уже заметили, что наши экстракторы никогда не возвращают None
.
Из примера видно, что в этом есть смысл. Если у нас есть объект, который может
иметь разные типы, мы можем проверить его тип и разобрать значение на составляющие
в одном выражении.
В нашем примере образец с FreeUser
не пройдёт, потому что тип значения, которое
мы передаём, не совпадает с тем, что ожидает метод unapply
для типа FreeUser
.
Поскольку мы передаём значение типа PremiumUser
, экстрактор из класса FreeUser
даже не будет вызван. Поэтому значение user
передаётся методу unapply
из
объекта-компаньона из класса PremiumUser
, так как он используется во втором
образце. Сопоставление с этим образцом пройдёт успешно и возвращаемое значение
будет связано с переменной name
.
Далее в этой статье нам ещё попадётся экстрактор, который не всегда возвращает Some[T]
.
Пусть теперь у нашего класса есть несколько полей:
trait User {
def name: String
def score: Int
}
class FreeUser(val name: String, val score: Int, val upgradeProbability: Double)
extends User
class PremiumUser(val name: String, val score: Int) extends User
Если экстрактор возвращает несколько значений, сигнатура метода unapply
выглядит так:
def unapply(object: S): Option[(T1, ..., Tn)]
Метод принимает некоторый объект типа S
и возвращает частично определённое значение
Option
типа TupleN
, где N
— число извлекаемых параметров.
Давайте перепишем наши экстракторы:
trait User {
def name: String
def score: Int
}
class FreeUser(val name: String, val score: Int, val upgradeProbability: Double)
extends User
class PremiumUser(val name: String, val score: Int) extends User
object FreeUser {
def unapply(user: FreeUser): Option[(String, Int, Double)] =
Some((user.name, user.score, user.upgradeProbability))
}
object PremiumUser {
def unapply(user: PremiumUser): Option[(String, Int)] = Some((user.name, user.score))
}
Теперь мы можем воспользоваться этим экстрактором при сопоставлении с образцом, точно так же как и в предыдущем примере:
val user: User = new FreeUser("Daniel", 3000, 0.7d)
user match {
case FreeUser(name, _, p) =>
if (p > 0.75) name + ", what can we do for you today?" else "Hello " + name
case PremiumUser(name, _) => "Welcome back, dear " + name
}
Иногда, при сопоставлении с образцом нам не нужно извлекать значения, мы всего лишь
хотим провести простое логическое сравнение. В этом случае, нам пригодится
третий (и последний) вариант метода unapply
. Он принимает значение типа S
и возвращает Boolean
:
def unapply(object: S): Boolean
Сопоставление с образцом пройдёт успешно, если экстрактор вернёт истину. В противном случае
сопоставление продолжится в следующей case
-альтернативе.
В предыдущем примере мы воспользовались условным оператором if-else
для проверки
возможности обновления учётной записи пользователя. Давайте перенесём эту логику
в отдельный экстрактор:
object premiumCandidate {
def unapply(user: FreeUser): Boolean = user.upgradeProbability > 0.75
}
Как видите, тип, на котором определён экстрактор, может не совпадать с типом
объекта-компаньона класса, на котором будет вызван метод unapply
.
Теперь мы можем с лёгкостью воспользоваться этим экстрактором при сопоставлении
с образцом:
val user: User = new FreeUser("Daniel", 2500, 0.8d)
user match {
case freeUser @ premiumCandidate() => initiateSpamProgram(freeUser)
case _ => sendRegularNewsletter(user)
}
Мы видим, что для применения экстрактора, возвращающего логические значения, необходимо передать ему пустой список аргументов, эта форма записи оправдана, поскольку в связывании переменных со значениями нет необходимости.
В этом примере есть одна тонкость: я предполагаю, что наша воображаемая функция initiateSpamProgram
принимает на вход только объекты класса FreeUser
, поскольку спам должен блокироваться для
привилегированных пользователей. Но наше сопоставление с образцом происходит для всех типов пользователей
(тип User
). Поэтому мы не можем передать функции initiateSpamProgram
значение типа User
.
Только если мы воспользуемся корявыми средствами приведения типов.
К счастью, в Scala с помощью оператора @
мы можем связать значение, которое проходит сопоставление, с переменной,
используя тип, который ожидает метод экстрактора. Так как экстрактор для premiumCandidate
принимает значение
типа FreeUser
, мы будем связывать переменную только со значениями типа FreeUser
.
Лично я почти не пользуюсь экстракторами, которые возвращают логические значения. Но нам совсем не помешает с ними познакомиться. Рано или поздно в Вашей практике может случиться подходящая для них ситуация.
В online-курсе на Corsera вы узнали, что мы можем извлекать значения из
списков и потоков почти точно так же, как и строить их: с помощью операторов
с двоеточием, соответственно ::
и #::
:
val xs = 58 #:: 43 #:: 93 #:: Stream.empty
xs match {
case first #:: second #:: _ => first - second
case _ => -1
}
Но как такое возможно? Ответ кроется в том, что в Scala предусмотрена альтернативная инфиксная форма
записи для экстракторов. Так, вместо e(p1, p2)
, где e
— экстрактор и p1
и p2
— извлекаемые параметры, мы всегда можем написать p1 e p2
.
Поэтому, образец в инфиксной форме записи head #:: tail
может быть переписан как #::(head, tail)
.
Точно так же мы можем переписать образец из предыдущего примера: name PremiumUser score
. Но на практике
так не поступают. Использование инфиксной формы записи образцов рекомендуется только для тех экстракторов,
которые выглядят как операторы, как в случае списков и потоков. Но такие экстракторы как для PremiumUser
будут выглядеть совсем плохо в инфиксной форме записи.
Хотя в том, как мы воспользовались оператором #::
при сопоставлении с образцом
нет ничего особенного, давайте присмотримся к нему повнимательней. Также в этом экстракторе
нам встретился метод unapply
, который, в зависимости от структуры аргумента, может возвращать None
.
Вот как экстрактор определён в исходном коде Scala 2.9.2:
object #:: {
def unapply[A](xs: Stream[A]): Option[(A, Stream[A])] =
if (xs.isEmpty) None
else Some((xs.head, xs.tail))
}
Если переданный поток пуст, мы возвращаем None
. Поэтому пустой поток не пройдёт
сопоставление с образцом head #:: tail
. В другом случае мы вернём пару из первого элемента
потока и остатка, который в свою очередь является потоком. Поэтому образец head #:: tail
пройдёт сопоставление с потоком, состоящим из одного и более элементов. Если поток содержит
лишь один элемент, переменная tail
будет связана с пустым потоком.
Чтобы понять, что происходит, перепишем наш пример в обычной (префиксной) записи:
val xs = 58 #:: 43 #:: 93 #:: Stream.empty
xs match {
case #::(first, #::(second, _)) => first - second
case _ => -1
}
Сначала будет вызван экстрактор для исходного потока xs
. Экстрактор вернёт Some(xs.head, xs.tail)
и
переменная first
будет связана со значением 58
, в то время как остаток xs
будет снова передан в экстрактор.
Он снова вернёт Tuple2
, обёрнутый в значение Some
. Переменная second
будет связана со значением 43
.
Остаток будет отброшен, поскольку он связан с _
.
Итак, когда и как нам стоит использовать экстракторы? Ведь в случае case
-классов
компилятор может определить их за нас.
Есть мнение, что case
-классы и сопоставление с образцом нарушают инкапсуляцию, связывая способ
извлечения данных с конкретной реализацией. Эта критика имеет корни в объектно-ориентированной
среде разработчиков. С точки зрения функционального программирования использование
case
-классов — очень хорошая практика. Они применяются для построения
алгебраических типов данных (algebraic data type — сокращённо ADT).
Обычно, реализация собственных экстракторов оправдана только в случае, если мы
хотим извлечь данные из объекта, реализация которого от нас скрыта, или мы хотим
определить дополнительные способы извлечения данных. Например, дополнительные экстракторы
используются для извлечения какой-нибудь полезной информации из строк.
В качестве упражнения Вы можете подумать о том, как реализовать URLextractor
,
извлекающий данные из строкового представления URL.
В первой части этой серии статей мы познакомились с экстракторами, рабочей лошадкой сопоставления с образцом в Scala. Мы узнали, как реализовать собственные экстракторы и какую роль реализация экстракторов играет при сопоставлении с образцом.
Некоторые возможности экстракторов не были затронуты в этой статье. Она и так уже достаточно велика. В следующей части мы к ним вернёмся. Мы узнаем как пишутся экстракторы, принимающие неопределённое число параметров.
Была ли эта статья полезна для Вас? Если что-то оказалось непонятным, обращайтесь.