В предыдущей главе мы рассмотрели различные способы применения образцов. В конце главы я кратко рассказал о возможности использования образцов в анонимных функциях. В этой главе мы подробно изучим эту тему.
Если вы прошли курс o Scala на Coursera или практиковались в Scala самостоятельно,
то наверняка довольно часто пользовались анонимными функциями. К примеру, для
того, чтобы преобразовать названия песен в списке так, чтобы все они записывались
прописными буквами (это может понадобиться для поиска), мы можем определить анонимную
функцию и передать её в метод map
:
val songTitles = List("The White Hare", "Childe the Hunter", "Take no Rogues")
songTitles.map(t => t.toLowerCase)
Мы можем записать ещё короче (через прочерк) с помощью специального синтаксиса для определения анонимных функций:
songTitles.map(_.toLowerCase)
Теперь давайте немного изменим задачу. У нас есть список пар из слов и их числа повторов в некотором тексте.
Нам нужно оставить только слова, для которых число повторов принадлежит некоторому интервалу значений, и вернуть только
сами слова. Нам нужно определить функцию: wordsWithoutOutliers(wordFrequencies: Seq[(String, Int)]): Seq[String]
:
Мы можем определить её с помощью методов filter
и map
, передав им анонимные функции.
val wordFrequencies = ("habitual", 6) :: ("and", 56) :: ("consuetudinary", 2) ::
("additionally", 27) :: ("homely", 5) :: ("society", 13) :: Nil
def wordsWithoutOutliers(wordFrequencies: Seq[(String, Int)]): Seq[String] =
wordFrequencies.filter(wf => wf._2 > 3 && wf._2 < 25).map(_._1)
wordsWithoutOutliers(wordFrequencies) // List("habitual", "homely", "society")
В этом решении есть несколько проблем. Первая — эстетического плана, обращение к полям кортежа выглядит очень не красиво. Если бы мы могли провести разбор пары в образце, код стал бы немного лучше и, возможно, нагляднее.
К счастью, в Scala есть специальный синтаксис для записи анонимных функций: анонимная
функция, в которой происходит сопоставление с образцом, может записана как блок кода, окружённый
фигурными скобками, который содержит набор case
-альтернатив. При этом мы пропускаем ключевое
слово match
перед блоком. Давайте перепишем наш пример в этой нотации:
def wordsWithoutOutliers(wordFrequencies: Seq[(String, Int)]): Seq[String] =
wordFrequencies.filter { case (_, f) => f > 3 && f < 25 } map { case (w, _) => w }
В этом примере каждый блок содержал лишь одну case
-альтернативу. В этом нет ничего страшного,
ведь мы всего лишь извлекаем значения из типа, структура которого нам известна на этапе компиляции.
Это общепринятая практика определения анонимных функций, которые проводят сопоставление с образцом.
Если мы попробуем присвоить такую анонимную функцию переменной, она будет иметь ожидаемый тип:
val predicate: (String, Int) => Boolean = { case (_, f) => f > 3 && f < 25 }
val transformFn: (String, Int) => String = { case (w, _) => w }
Обратите внимание на то, что нам необходимо указать тип значения. Компилятор Scala не может вывести тип для анонимных функций с сопоставлением с образцом.
Мы можем определять более сложные функции с большим числом case
-альтернатив.
Но при этом необходимо помнить о том, что функция должна возвращать значение
для любых аргументов, сопоставление с образцом должно пройти успешно хотя бы в одной
из case
-альтернатив, иначе мы рискуем напороться на MatchError
на этапе
выполнения программы.
Но иногда, нам как раз нужно определить функцию, которая определена не на всех значениях. Такая функция могла бы решить вторую проблему в нашем примере с парами слов и повторов. Мы сначала провели фильтрацию и затем преобразовали оставшиеся элементы. Если бы мы могли предложить решение в один проход, мы бы не только сэкономили несколько циклов CPU, но и сделали бы наш код более кратким и наглядным.
Если вы заглянете в стандартную библиотеку коллекций, вы заметите метод collect
,
который для значения Seq[A]
имеет следующий тип:
def collect[B](pf: PartialFunction[A, B])
Этот метод возвращает новую последовательность, применяя переданную частично определённую функцию. Эта функция одновременно фильтрует и преобразует элементы последовательности.
Но что же такое на самом деле частично определённая функция? Эта функция, определённая для одного аргумента, которая определена только для некоторых значений аргумента. Она позволяет проверить: определено значение или нет.
Как раз для этого в трэйте PartialFunction
определён метод isDefinedAt
. На самом деле,
PartialFunction[-A, +B]
наследует от типа (A) => B
(что является краткой записью для Function1[A, B]
).
Поэтому анонимная функция с сопоставлением с образцом также имеет тип PartialFunction
.
В рамках этой иерархии наследования вполне допустимо передавать такую анонимную функцию
в методы, ожидающие на входе Function1
, если в анонимной функции для любого значения
найдётся соответствующая ему case
-альтернатива.
Но метод collect
принимает PartialFunction[A, B]
, она может быть не определена для
некоторых значений. Он знает как справиться с такими значениями. Для каждого элемента
последовательности сначала проверяется, определён ли он, происходит вызов метода isDefinedAt
.
Если метод возвращает ложь, то элемент отбрасывается, иначе результат применения частично
определённой функции добавляется в результирующую последовательность.
Но давайте вернёмся к нашему примеру и напишем частично определённую функцию для метода collect
:
val pf: PartialFunction[(String, Int), String] = {
case (word, freq) if freq > 3 && freq < 25 => word
}
Мы добавили охранное выражение, чтобы функция была не определена для пар, в которых число повторов слова не попадает в заданный интервал.
В явном виде (без специального синтаксиса) определение этой функции будет иметь вид:
val pf = new PartialFunction[(String, Int), String] {
def apply(wordFrequency: (String, Int)) = wordFrequency match {
case (word, freq) if freq > 3 && freq < 25 => word
}
def isDefinedAt(wordFrequency: (String, Int)) = wordFrequency match {
case (word, freq) if freq > 3 && freq < 25 => true
case _ => false
}
}
Но мы будем пользоваться более наглядным синтаксисом для таких функций.
Теперь если мы передадим такую функцию методу map
, то наш код успешно скомпилируется,
но в результате мы получим MatchError
на этапе выполнения, поскольку наша
функция определена не для всех значений.
wordFrequencies.map(pf) // will throw a MatchError
Но мы можем передать нашу функцию методу collect
, что приведёт к одновременной фильтрации и
преобразованию последовательности, как и ожидалось:
wordFrequencies.collect(pf) // List("habitual", "homely", "society")
Давайте перепишем наше исходное определение в виде функции:
def wordsWithoutOutliers(wordFrequencies: Seq[(String, Int)]): Seq[String] =
wordFrequencies.collect { case (word, freq) if freq > 3 && freq < 25 => word }
Частично определённые функции обладают и другими полезными свойствами. К примеру, мы можем легко выстраивать из них цепочки, этот приём является альтернативой шаблону цепочки обязанностей (chain of reponsibility pattern). Мы вернёмся к этой теме в одной из следующих статей, когда мы будем говорить о композиции функций.
Частично определённые функции также играют ключевую роль во многих библиотеках. К примеру, в Akka акторы обрабатывают сообщения, посланные им, с помощью частично определённой функции. Поэтому понимание этого понятия очень важно.
В этой части мы рассмотрели альтернативный способ определения анонимных функций
с помощью последовательности case
-альтернатив. Такой способ позволяет очень
наглядно выражать извлечение данных из аргументов анонимных функций. Также мы
познакомились с частично определёнными функциями, на простом примере мы разобрались
с тем, как они работают.
Следующая статья будет посвящена вездесущему типу Option
. Мы узнаем, зачем он нужен
и как пользоваться им наилучшим образом.