Принципы функционального программирования в Scala. Глава 1: Экстракторы

Принципы функционального программирования в Scala. Глава 1: Экстракторы

https://t.me/nuancesprog

Перевод статьи Martin Odersky: The Neophyte's Guide to Scala. Part 1: Extractors

Более 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 будут выглядеть совсем плохо в инфиксной форме записи.

Присмотримся к экстрактору для Stream

Хотя в том как мы воспользовались оператором #:: при сопоставлении с образцом нет ничего особенного, давайте присмотримся к нему по-внимательней. Также в этом экстракторе нам встретился метод 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. Мы узнали как реализовать собственные экстракторы и какую роль реализация экстракторов играет при сопоставлении с образцом.

Некоторые возможности экстракторов не были затронуты в этой статье. Она и так уже достаточно велика. В следующей части мы к ним вернёмся. Мы узнаем как пишутся экстракторы, принимающие неопределённое число параметров.


Report Page