Прошлой статья была посвящена вопросам устранения дублирования. Для устранения повторов мы либо переписывали функции по-другому (определяли более общие функции), либо выражали одни функции через композицию других. В этой статье мы посмотрим на две новые возможности языка Scala, которые также позволят нам существенно снизить дублирование кода. Это частичное применение функций и каррирование.
В Scala как и во многих других языках, поддерживающих функциональное программирование, мы можем применять функцию частично. Это означает, что при вызове функции мы передаём ей лишь часть параметров, оставшиеся же будут пустыми. При этом мы получим новую функцию, которая будет принимать те аргументы, которые мы оставили пустыми.
Не путайте частичное применение функций с частично определёнными функциями (типом PartialFunction
).
Давайте посмотрим как это работает на примере. Для этого вернёмся к примеру из прошлой статьи. Для нашего воображаемого почтового сервиса мы хотели чтобы пользователь мог настраивать фильтрацию входящих сообщений, так чтобы он получал лишь письма, удовлетворяющие некоторым требованиям.
Наш case
-класс Email
остаётся прежним:
case class Email(
subject: String,
text: String,
sender: String,
recipient: String)
type EmailFilter = Email => Boolean
Критерий фильтрации писем описывается предикатом Email => Boolean
, мы дали ему синоним EmailFilter
.
Мы могли определять новые предикаты через уже определённые методы-фабрики.
Два метода из прошлой статьи создавали фильтры на основе проверки максимальной и минимальной длины письма.
На этот раз мы хотим воспользоваться частичным применением функции для определения этих методов.
Мы хотим создать обобщённый метод sizeConstraint
, так чтобы частные фильтры получались с помощью
подстановки части его параметров.
Вот наш метод sizeConstraint
:
type IntPairPred = (Int, Int) => Boolean
def sizeConstraint(pred: IntPairPred, n: Int, email: Email) = pred(email.text.size, n)
Мы определили синоним для предиката, проверяющего пары целых чисел (некоторое число n
и
размер длины письма).
Обратите внимание на то, что в отличие от предыдущей версии, функция sizeConstraint
не возвращает теперь предикат EmailFilter
, но просто вычисляет все аргументы, переданные
в функцию и возвращает Boolean
. Трюк состоит в том, чтобы получить нужный предикат
с помощью частичного применения.
Но сначала, поскольку мы всерьёз решили не повторяться, давайте определим
основные предикаты IntPairPred
. После этого при вызове sizeConstraint
нам не придётся раз за разом выписывать анонимные функции:
val gt: IntPairPred = _ > _
val ge: IntPairPred = _ >= _
val lt: IntPairPred = _ < _
val le: IntPairPred = _ <= _
val eq: IntPairPred = _ == _
Наконец, всё готово для того чтобы выполнить частичное применение функции sizeConstraint
.
Мы зафиксируем первый аргумент с одним из наших значений для IntPairPred
:
val minimumSize: (Int, Email) => Boolean = sizeConstraint(ge, _: Int, _: Email)
val maximumSize: (Int, Email) => Boolean = sizeConstraint(le, _: Int, _: Email)
Как видно из определений, нам необходимо воспользоваться прочерком для обозначения пропущенных аргументов. К сожалению, нам также пришлось явно указать типы пропущенных аргументов. Поэтому частичное применение в Scala может быть несколько занудным.
Компилятор Scala не может вывести эти типы самостоятельно, по крайней мере не для всех случаев — например, для перегруженных методов компилятор не сможет понять к какому методу относится вызов.
С другой стороны теперь у нас есть выбор какие параметры оставить. к примеру, мы можем оставить первый параметр и зафиксировать размер письма:
val constr20: (IntPairPred, Email) => Boolean = sizeConstraint(_: IntPairPred, 20, _: Email)
val constr30: (IntPairPred, Email) => Boolean = sizeConstraint(_: IntPairPred, 30, _: Email)
Теперь у нас есть две функции, которые принимают IntPairPred
и Email
и сравнивают размер
письма с числами 20
и 30
. Но то как они проводят сравнения не фиксируется. Как раз за это и отвечает
IntPairPred
.
На этом примере видно, что несмотря на многословность, частичное применение в Scala всё же немного более общее чем в Clojure, где мы обязаны передавать аргументы по-порядку слева направо, но не можем пропустить параметр в середине.
При частичном применении методов мы можем не связывать ни один из параметров. Тогда список аргументов для возвращаемого функционального объекта будет совпадать с исходным списком аргументов для метода. Так мы можем превратить метод в функцию, которая может быть присвоена переменной или передана в метод.
val sizeConstraintFn: (IntPairPred, Int, Email) => Boolean = sizeConstraint _
У нас всё ещё нет ни одной функции, которая возвращала бы EmailFilter
.
Ведь sizeConstraint
, minimumSize
и maximumSize
— ни одна из этих функций
не возвращает EmailFilter
. Все они возвращают Boolean
как видно из сигнатур их типов.
Но наши фильтры находятся лишь в одном частичном применении от нас.
Зафиксировав целочисленный параметр для minimumSize
и maximumSize
мы
можем создать новые функции типа EmailFilter
:
val min20: EmailFilter = minimumSize(20, _: Email)
val max20: EmailFilter = maximumSize(20, _: Email)
Мы могли выполнить то же самое через частичное применение функции constr20
:
val min20: EmailFilter = constr20(ge, _: Email)
val max20: EmailFilter = constr20(le, _: Email)
Возможно частичное применение функции показалось Вам слишком многословным или просто не таким элегантным. К счастью, у нас есть альтернатива.
Методы в Scala могут иметь несколько списков аргументов. Давайте перепишем
sizeConstraint
так, чтобы каждый аргумент находился бы в своём списке.
def sizeConstraint(pred: IntPairPred)(n: Int)(email: Email): Boolean =
pred(email.text.size, n)
Если мы превратим этот метод в функциональны объект, пригодный для присваивания или передачи в другие методы, сигнатура новой функции будет иметь вид:
val sizeConstraintFn: IntPairPred => Int => Email => Boolean = sizeConstraint _
Такая цепочка функций с одним параметром называется каррированой функцией, она названа в честь Хаскела Карри, он изобрёл этот метод. В языке Haskell все функции являются каррированными по умолчанию.
В нашем примере она принимает IntPairPred
и возвращает функцию, которая принимает
Int
и возвращает новую функцию. Последняя функция принимает Email
и возвращает
Boolean
.
Теперь, если мы захотим связать IntPairPred
со значением, мы просто подставим значение
в функцию sizeConstraintFn
, которая принимает в точности один аргумент и возвращает
функцию одного аргумента:
val minSize: Int => Email => Boolean = sizeConstraint(ge)
val maxSize: Int => Email => Boolean = sizeConstraint(le)
Теперь нам не нужно использовать прочерки для пропущенных параметров, потому что мы не делаем частичное применение.
Теперь мы можем определить точно такие же предикаты, что и в случае частичного применения:
val min20: Email => Boolean = minSize(20)
val max20: Email => Boolean = maxSize(20)
Конечно мы можем выполнить всё это и за один шаг, подставив несколько параметров. В этом случае мы сразу подставляем значение в только что полученную функцию, и пропускаем шаг присваивания к промежуточной переменной:
val min20: Email => Boolean = sizeConstraintFn(ge)(20)
val max20: Email => Boolean = sizeConstraintFn(le)(20)
Не всегда мы знаем заранее в каком виде нам понадобится функция — с каррированием или без. Обычный вызов для каррированных функций выглядит немного более громоздким, чем в случае функций с одним списком аргументов. Кроме того иногда мы хотим воспользоваться каррированием в сторонних библиотечных функциях, которые определены с одним списком аргументов.
Но преобразование функции со многими параметрами к каррированной форме записи можно запросто сделать
с помощью функции высшего порядка. И у нас есть такая функция. Мы можем сделать это вызовом метода curried
для исходной функции. Так если у нас есть функция sum
, принимающая два аргумента, мы можем получить
каррированную версию функции просто вызвав метод curried
:
val sum: (Int, Int) => Int = _ + _
val sumCurried: Int => Int => Int = sum.curried
Для обратного преобразования есть метод Function.uncurried
. Он ожидает на вход
каррированную функцию.
В заключение статьи, давайте посмотрим как каррированные функции могут применяться на уровне проектирования приложений. Если Вы пришли из мира промышленных приложений на Java или .NET, Вам наверняка очень знакома необходимость использования внедрения зависимостей. Этот приём берёт на себя подключение зависимостей к объектам. В Scala нет сторонних средств для реализации этой техники, потому что в самом языке определено несколько конструкций, позволяющих делать внедрение зависимостей, гораздо более простым способом.
При программировании в функциональном стиле всё равно возникает необходимость внедрения зависимостей. Функции, находящиеся на более высоком уровне приложения, будут вызывать другие функции. Если эти вызовы будут зашиты в код программы, нам будет очень сложно тестировать их вне контекста приложения. Поэтому нам необходимо чтобы функция принимала на вход все высокоуровневые функции, от которых она зависит.
Но это может привести к излишнему дублированию кода, если мы будем постоянно передавать все зависимости при вызове функции. Как раз для этого случая и подходят каррирование. Каррирование и частичное применение — один из способов организации внедрения зависимостей в функциональном стиле.
Посмотрим как это делается на следующем примере:
case class User(name: String)
trait EmailRepository {
def getMails(user: User, unread: Boolean): Seq[Email]
}
trait FilterRepository {
def getEmailFilter(user: User): EmailFilter
}
trait MailboxService {
def getNewMails(emailRepo: EmailRepository)(filterRepo: FilterRepository)(user: User) =
emailRepo.getMails(user, true).filter(filterRepo.getEmailFilter(user))
val newMails: User => Seq[Email]
}
У нас есть сервис, который зависит от двух разных репозиториев.
Эти зависимости определены в виде аргументов метода getNewMails
,
причём каждый находится в своём собственном списке аргументов.
В сервисе MailBoxService
реализовано всё кроме поля newMails
.
Это функция типа User => Seq[Email]
. Её вызов будет зависеть от
тех компонентов, которые будут использованы в MailboxService
.
Нам нужен объект, который будет наследовать от MailboxService
.
Основная идея в том, чтобы определить newMails
через связывание
первых двух аргументов метода getNewMails
с конкретной реализацией
зависимостей для EmailRepository
и FilterRepository
:
object MockEmailRepository extends EmailRepository {
def getMails(user: User, unread: Boolean): Seq[Email] = Nil
}
object MockFilterRepository extends FilterRepository {
def getEmailFilter(user: User): EmailFilter = _ => true
}
object MailboxServiceWithMockDeps extends MailboxService {
val newMails: (User) => Seq[Email] =
getNewMails(MockEmailRepository)(MockFilterRepository) _
}
Теперь мы можем вызывать MailboxServiceWithMoxDeps.newMails(User("daniel"))
без явной передачи дополнительных зависимостей. Но в настоящем приложении
мы скорее всего будем пользоваться не конкретной реализацией сервиса, но
трэйтом, также пригодным для внедрения зависимостей.
Возможно это не лучший способ внедрения зависимостей в Scala, но нам будет совсем не лишним знать о нём, к тому же это хороший пример использования каррирования и частичного применения функций на практике. Для подробного изучения этого вопроса я рекомендую вам взглянуть на превосходную презентацию Debasish Ghosh ”Dependency Injection in Scala”. Я почерпнул эти знания как раз из них.
В этой статье мы обсудили две дополнительные возможности для программирования в функциональном стиле, с его помощью мы можем существенно снизить дублирование, сохраняя гибкость кода, он стимулирует написание обобщённых функций, которые могут применяться для определения многих частных случаев. Частичное применение и каррирование выполняют по-сути одну и ту же роль, но в том или ином случае одна из форм может оказаться более элегантной.
В следующей статье мы узнаем как повысить гибкость кода с помощью классов типов.