Java.next: Карринг и частичное применение

Новые мощные возможности для работы с функциями

Все языки Java.next поддерживают такие приемы, как "карринг" (currying) и "частичное применение" (partial application), однако реализуют их по-разному. В данной статье объясняются оба эти приема и разлВсе языки Java.next поддерживают такие приемы, как "карринг" (currying) и "частичное применение" (partial application), однако реализуют их по-разному. В данной статье объясняются оба эти приема и различия между ними, а также демонстрируются детали их реализации — и практическое применение — в языках Scala, Groovy и Clojure.

Нил Форд, Архитектор приложений, ThoughtWorks

Нил Форд (Neal Ford) работает архитектором приложений в ThoughtWorks, революционной компании, предоставляющей профессиональные IT-услуги и помогающей талантливым людям по всему миру эффективнее использовать программное обеспечение. Он также является проектировщиком и разработчиком приложений, учебных материалов, журнальных статей, учебных курсов, видео/DVD-презентаций и автором книг "Разработка с Delphi: Объектно-ориентированный подход", "JBuilder 3 Unleashed" и "Искусство разработки Web-приложений на Java". Он специализируется на консультациях по построению широкомасштабных корпоративных приложений. Он также является общепризнанным докладчиком и выступал на многочисленных конференциях разработчиков по всему миру. С ним можно связаться по адресу: nford@thoughtworks.com.



25.03.2014

Об этом цикле статей

Java-технология оставит в наследство не язык, а платформу. На платформе JVM работают более 200 языков программирования; какой-то из них со временем неминуемо вытеснит язык Java в качестве наилучшего способа программирования для JVM. Этот цикл статей посвящен исследованию трех языков нового поколения для платформы JVM — Groovy, Scala и Clojure — а также сравнению и противопоставлению новых возможностей и парадигм, чтобы дать Java-разработчикам возможность заглянуть в свое ближайшее будущее.

Карринг и частичное применение — это языковые приемы, взятые из математики (в первую очередь из написанных в XX веке работ Хаскелла Карри (Haskell Curry) и других математиков). Эти приемы встречаются в языках различных типов, а в функциональных языках они весьма распространены — оба или, как минимум, какой-либо один из них. Карринг и частичное применение позволяют манипулировать количеством аргументов у функций или методов. Обычно это осуществляется посредством т. н. фиксации аргументов (задание одного или нескольких значений по умолчанию для некоторых аргументов). Все языки Java.next поддерживают карринг и частичное применение, однако реализуют их по-разному. В этой статье я объясняю различия между этими двумя приемами и демонстрирую детали их реализации для языков Scala, Groovy и Clojure, а также их практическое применение.

Примечание относительно терминологии

Применительно к данной статье термины метод и функция являются равнозначными. В объектно-ориентированных языках, которые поддерживают карринг и частичное применение, используются методы. Термины параметр функции и аргумент функции также являются равнозначными. Поскольку эти концепции пришли из математики, я постоянно пользуюсь терминами функция и аргумент, однако это не означает, что описываемые в статье приемы не работают с методами.

Открытая бета-программа IBM SDK for Java 8

IBM Worklight Developer Edition download

Определения и отличительные признаки

На первый может показаться, что карринг и частичное применение обеспечивают одинаковые результаты. Оба эти приема позволяют создать версию функции с предварительно заданными значениями для некоторых аргументов: :

  • Карринг— этот термин описывает преобразование функции с несколькими аргументами в цепочку функций, каждая из которых имеет по одному аргументу. Термин «карринг» относится к процессу преобразования, а не к вызову преобразованной функции. Вызывающая сторона может принять решение о том, сколько аргументов должно применяться, а затем — согласно этому решению — создать вторичную функцию с соответственно уменьшенным количеством аргументов.
  • Частичное применение— этот термин описывает преобразование функции с несколькими аргументами в функцию, которая принимает меньшее количество аргументов, при этом значения для опущенных аргументов задаются заранее. Этот прием вполне адекватен своему названию: он "частично применяет" некоторые аргументы функции, возвращая функцию с сигнатурой, которая состоит из остающихся аргументов.

И при карринге, и при частичном применении вы задаете значения аргументов и возвращаете функцию, которая может быть вызвана с опущенными аргументами. Следует, однако, отметить, что карринг функции возвращает следующую функцию в цепочке, а частичное применение связывает значения аргументов со значениями, которые вы предоставляете во время операции, что порождает функцию меньшей арности (количество аргументов). Это различие становится более наглядным при рассмотрении функций с арностью более двух. Например, полностью "каррированная" версия функции process(x, y, z) имеет вид process(x)(y)(z), где process(x) и process(x)(y)— это функции, каждая из которых принимает один аргумент. Если вы осуществляется карринг только первого аргумента, то возвращаемое значение функции process(x)— это функция, которая принимает единственный аргумент, который, в свою очередь, принимает единственный аргумент. Иная картина имеет место в случае частичного применения — вы получаете функцию с меньшей арностью. Задействование частичного применения для одного аргумента функции process(x, y, z) порождает функцию, которые принимает два аргумента: process(y, z).

Эти два приема зачастую дают одинаковые результаты, тем не менее они имеет важные различия, которые во многих случаях истолковываются неправильно. Ситуация дополнительно усложняется в языке Groovy, в котором реализованы и частичное применение, и карринг, но при этом в названии обоих этих приемов используется слово curry. В языке Scala имеются частично применяемые функции и опция PartialFunction. Это разные концепции, несмотря на схожие имена.


При использовании Scala

Язык Scala поддерживает и карринг, и частичное применение, а также т.н. "трейты", что обеспечивает возможность определения ограниченных функций.

Карринг

В Scala функции позволяют задавать несколько списков аргументов виде наборов круглых скобок. Если вы вызываете какую-либо функцию с меньшим количеством аргументов, чем задано для нее, эта функция берет списки опущенных аргументов в качестве своих аргументов. Рассмотрим пример из документации Scala, показанный в листинге 1.

Карринг аргументов в Scala
def filter(xs: List[Int], p: Int => Boolean): List[Int] =
    if (xs.isEmpty) xs
    else if (p(xs.head)) xs.head :: filter(xs.tail, p)
    else filter(xs.tail, p)

def modN(n: Int)(x: Int) = ((x % n) == 0)

val nums = List(1, 2, 3, 4, 5, 6, 7, 8)
println(filter(nums, modN(2)))
println(filter(nums, modN(3)))

В листинге 1 функция filter() рекурсивно применяет переданные критерии фильтрации. Функция modN() определена с двумя списками аргументов. Когда я вызываю функцию modN с использованием функции filter(), я передаю единственный аргумент. Функция filter() принимает в качестве своего второго аргумента функцию с аргументом Int и возвращает значение Boolean соответствующее сигнатуре каррированной функции, которую я передаю.

Частичное применение функций

Язык Scala также допускает частичное применение функций(см. листинг 2).

Частичное применение функций в Scala
def price(product : String) : Double =
  product match {
    case "apples" => 140
    case "oranges" => 223
}

def withTax(cost: Double, state: String) : Double =
  state match {
    case "NY" => cost * 2
    case "FL" => cost * 3
}


val locallyTaxed = withTax(_: Double, "NY")
val costOfApples = locallyTaxed(price("apples"))

assert(Math.round(costOfApples) == 280)

В листинге 2 я сначала создаю функцию price, которая возвращает отображение между product (товар) и price (цена). Затем я создаю функцию withTax(), которая принимает аргументы cost и state. Однако для определенного исходного файла я знаю, что буду иметь дело с налогами (taxes) только одного штата (state). Вместо того чтобы "каррировать" лишний аргумент при каждом вызове, я "частично применяю" аргумент state и возвращаю версию функции, в которой значение state зафиксировано. Функция locallyTaxed принимает единственный аргумент cost.

Частичные (ограниченные) функции

Трейт PartialFunction языка Scala предназначен для прозрачного применения сопоставления с шаблоном. Сопоставление с шаблоном (pattern matching) описано в статье "Создание деревьев с помощью Either и использование сопоставления с шаблоном" моего цикла статей "Функциональное мышление". Несмотря на схожее название, этот трейт не создает частично применяемую функцию. Вместо этого он позволяет определить функцию, которая работает только с определенным подмножеством значений и типов.

Одним из способов частичного применения функций являются case-блоки. В листинге 3 конструкт case языка Scala используется без традиционного соответствующего ему оператора match.

Использование case без match
val cities = Map("Atlanta" -> "GA", "New York" -> "New York",
  "Chicago" -> "IL", "San Francsico " -> "CA", "Dallas" -> "TX")

cities map { case (k, v) => println(k + " -> " + v) }

IВ листинге 3 я создаю словарь соответствия городов и штатов. Затем я вызываю функцию map с коллекций, после чего функция map осуществляет поочередный вывод на печать пар "ключ/значение". Фрагмент кода, содержащий утверждения case— это один из способов определения анонимной функции в языке Scala. Анонимные функции можно определять более лаконично, без использования case, однако синтаксис с case обеспечивает дополнительные выгоды (см. листинг 4).

Различия между map и collect
List(1, 3, 5, "seven") map { case i: Int ? i + 1 } // won't work
// scala.MatchError: seven (of class java.lang.String)

List(1, 3, 5, "seven") collect { case i: Int ? i + 1 }
// verify
assert(List(2, 4, 6) == (List(1, 3, 5, "seven") collect { case i: Int ? i + 1 }))

В листинге 4 я не могу использовать map при наличии гетерогенной коллекции с утверждением case: Я получаю ошибку MatchError, поскольку функция пытается прирастить строковое значение seven. Однако collect работает корректно. В чем причина этого различия и почему не возникает ошибка?

Case-блоки определяют частичные функции, но не частично применяемые функции. Частичные функции имеют ограниченный диапазон допустимых значений. Например, математическая функция 1/x не имеет смысла при x = 0. Частичные функции позволяют определять ограничения для допустимых значений. В примере collect в листинге 4 case определяется для Int, но не для String, поэтому строка seven не подпадает под действие collect.

Чтобы определить частичную функцию, также можно воспользоваться трейтом PartialFunction (см. листинг 5).

Определение частичной функции в языке Scala
val answerUnits = new PartialFunction[Int, Int] {
    def apply(d: Int) = 42 / d
    def isDefinedAt(d: Int) = d != 0
}

assert(answerUnits.isDefinedAt(42))
assert(! answerUnits.isDefinedAt(0))

assert(answerUnits(42) == 1)
//answerUnits(0)
//java.lang.ArithmeticException: / by zero

В листинге 5 я получаю answerUnits из трейта PartialFunction и предоставляю две функции: apply() и isDefinedAt(). Функция apply() вычисляет значения. Я использую функцию isDefinedAt()—— обязательную для трейта PartialFunction—— с целью задания ограничений, которые определяют допустимость аргументов.

Поскольку частичные функции можно также реализовать с помощью case-блоков, функцию answerUnits из листинга 5 можно определить более компактно (см. листинг 6).

Альтернативное определение функции answerUnits
def pAnswerUnits: PartialFunction[Int, Int] =
    { case d: Int if d != 0 => 42 / d }

assert(pAnswerUnits(42) == 1)
//pAnswerUnits(0)
//scala.MatchError: 0 (of class java.lang.Integer)

В листинге 6 я использую case совместно со "сторожевым" условием с целью ограничения значений и одновременного предоставления результатов. Важное отличие от листинга 5— наличие MatchError (а не ArithmeticException) — поскольку в листинге 6 используется сопоставление с шаблоном.

Частичные функции не ограничены числовыми типами. Можно использовать все типы, в том числе Any. Рассмотрим реализацию incrementer(см. листинг 7).

Определение incrementer в Scala
def inc: PartialFunction[Any, Int] =
    { case i: Int => i + 1 }

assert(inc(41) == 42)
//inc("Forty-one")
//scala.MatchError: Forty-one (of class java.lang.String)

assert(inc.isDefinedAt(41))
assert(! inc.isDefinedAt("Forty-one"))

assert(List(42) == (List(41, "cat") collect inc))

В листинге 7 я определяю частичную функцию, чтобы принимать все типы входных данных (Any), но реагировать только на подмножество этих типов. Однако обратите внимание, что я также могу вызвать функцию isDefinedAt(), чтобы воспользоваться частичной функцией. Implementer-компоненты в трейте PartialFunction, в котором используется case, могут вызвать функцию isDefinedAt(), определенную неявным образом. В листинге 4 я продемонстрировал, что map и collect ведут себя по-разному. Это различие обуславливается поведением частичных функций: функция collect принимает частичные функции и вызывает функцию isDefinedAt() для соответствующих элементов (игнорируя несоответствующие элементы).

Частичные функции и частично применяемые функции в Scala имеют схожие названия, однако предлагают совершенно различные наборы возможностей. К примеру, ничто не мешает нам осуществить частичное применение частичной функции.


При использовании Groovy

Я достаточно подробно рассмотрел использование карринга и частичного применения в языке Groovy в статье "Функциональное мышление: Часть 3. Разработка программ в функциональном стиле", которая входит в цикл статей "Функциональное мышление". В Groovy карринг реализуется с помощью функции curry(), которая берет свое начало в классе Closure. Несмотря на свое имя, функция curry() фактически реализует частичное применение посредством манипулирования нижележащими closure-блоками. Тем не менее карринг можно имитировать, используя частичное применение для редуцирования функции до серии частично применяемых функций с единственным аргументом (см. листинг 8).

Частичное применение и карринг в Groovy
def volume = { h, w, l -> return h * w * l }
def area = volume.curry(1)
def lengthPA = volume.curry(1, 1) //partial application
def lengthC = volume.curry(1).curry(1) // currying

println "The volume of the 2x3x4 rectangular solid is ${volume(2, 3, 4)}"
println "The area of the 3x4 rectangle is ${area(3, 4)}"
println "The length of the 6 line is ${lengthPA(6)}"
println "The length of the 6 line via curried function is ${lengthC(6)}"

В листинге 8 в обоих случаях использования length я задействую функцию curry() с целью частичного применения аргументов. Однако в случае с lengthC, я создаю иллюзию карринга посредством частичного применения аргументов до тех пор, пока не останется цепочка функций с единственным аргументом.


При использовании Clojure

В языке Clojure имеется функция (partial f a1 a2 ...), которая принимает функцию f с количеством аргументов, редуцированным относительно исходного значения, и возвращает частично примененную функцию, которую можно будет вызвать, когда вы предоставите остальные аргументы. Два соответствующих примера показано в листинге 9.

Частичное применение в языке Clojure
(def subtract-from-hundred (partial - 100))

(subtract-from-hundred 10)      ; same as (- 100 10)
; 90

(subtract-from-hundred 10 20)   ; same as (- 100 10 20)
; 70

В листинге 9 я определяю функцию subtract-from-hundred как частично примененный оператор (в языке Clojure операторы неотличимы от функций) и предоставляю значение 100 в качестве частично примененного аргумента. Эти два примера в листинге 9 демонстрируют, что частичное применение в Clojure работает как для функций с единственным аргументом, так и для функций с несколькими аргументами.

В Clojure имеется динамическая типизация и поддержка изменяемых списков аргументов, поэтому карринг не реализован в качестве языковой возможности. Частичное применение удовлетворяет все возникающие потребности. Тем не менее частная функция пространства имен (defcurried ...), добавленная в Clojure-библиотеку reducers (см. раздел Ресурсы), существенно упрощает определение некоторых функций в этой библиотеке. Учитывая гибкую природу Clojure, унаследованную от языка Lisp, расширение масштабов использования (defcurried ...) является тривиальной задачей.


Типичные сценарии использования

Несмотря на сложные определения и бесчисленные детали реализации, карринг и частичное применение действительно нашли свое место в практическом программировании.

Фабрики функций

Карринг (и частичное применение) хорошо работает в тех случаях, когда вы реализуете "фабричную" функцию в традиционных объектно-ориентированных языках. В следующем примере (см. листинг 10) реализована простая функция adder на языке Groovy.

Функции adder и incrementer в Groovy
def adder = { x, y -> x + y}
def incrementer = adder.curry(1)

println "increment 7: ${incrementer(7)}" // 8

В листинге 10 я использую функцию adder() с целью получения функции incrementer. Подобным образом в листинге 2 я использую частичное применение с целью локального создания более лаконичной версии функции.

Шаблон проектирования Template Method

Template Method — это один из шаблонов проектирования из классической книги «Шаблоны проектирования» Э. Гамма с соавторами. Он полезен при определении алгоритмических оболочек, которые используют внутренние абстрактные методы с целью обеспечения гибкости при последующей реализации. Частичное применение и карринг способны решить эту же задачу. Использование частичного применения для представления известного поведения (с возможностью задействования остальных аргументов для специфических особенностей реализации) воспроизводит реализацию этого объектно-ориентированного шаблона проектирования.

Неявное предоставление значений

Весьма распространенной является ситуация (подобная показанной в листинге 2), в которой имеется серия вызовов функции с подобными значениями аргументов. Например, при взаимодействии со средой персистентности вам необходимо передавать источник данных в качестве первого аргумента. Посредством частичного применения вы можете задавать соответствующее значение неявным образом (см. листинг 11).

Использование частичного применения для неявного задания значений
(defn db-connect [data-source query params]
      ...)

(def dbc (partial db-connect "db/some-data-source"))

(dbc "select * from %1" "cust")

В листинге 11 я использую вспомогательную функцию dbc для доступа к функциям данных без указания источника данных, поскольку он задается автоматически. Сущность объектно-ориентированного программирования — идея неявного контекста this, который как по волшебству появляется в каждой функции — может быть реализована посредством использования карринга с целью предоставления контекста this каждой функции, что делает его невидимым для потребителей.

Заключение

Карринг и частичное применение принимают в языках Java.next различные формы. Вы можете использовать любой из этих приемов, чтобы сделать определения функций более лаконичными, задавать значения неявным образом и создавать фабрики функций.

В следующем статье я продемонстрирую удивительное сходство между возможностями функционального программирования у всех языков Java.next, а также рассмотрю различия в реализации этих возможностей — в некоторых случаях весьма существенные.

Ресурсы

Научиться

  • Оригинал статьи: Java.next: Currying and partial application.
  • Scala: современный функциональный язык, работающий на платформе JVM.
  • Groovy: динамическая разновидность Java с обновленным синтаксисом и расширенными возможностями.
  • Clojure: современный функциональный вариант Lisp, работающий на платформе JVM.
  • What's the difference between Currying and Partial Application? (В чем состоит разница между каррингом и частичным применением): статья в популярном блоге для разработчиков Raganwald.
  • reducers: Библиотека reducers — это мощное расширение Clojure, поддерживающее изощренный параллельный доступ к таким операциям, как map.
  • Функциональное мышление: колонка Нила Форда на developerWorks, посвященная функциональному программированию.
  • Записная книжка дизайнера языка:: в этом цикле статей developerWorks архитектор языка Java Брайан Гетц исследует некоторые проблемы дизайна языка, вызвавшие трудности при эволюции языка Java версий Java SE 7, Java SE 8 и далее.
  • Раздел developerWorks, посвященный Java-технологии:: Сотни статей по всем аспектам программирования на Java.

Получить продукты и технологии

Обсудить

  • Присоединяйтесь к сообществу developerWorks community. Связывайтесь с другими пользователями developerWorks и знакомьтесь с ориентированными на разработчиков форумами, блогами, группами и вики-ресурсами

Комментарии

developerWorks: Войти

Обязательные поля отмечены звездочкой (*).


Нужен IBM ID?
Забыли Ваш IBM ID?


Забыли Ваш пароль?
Изменить пароль

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Профиль создается, когда вы первый раз заходите в developerWorks. Информация в вашем профиле (имя, страна / регион, название компании) отображается для всех пользователей и будет сопровождать любой опубликованный вами контент пока вы специально не укажите скрыть название вашей компании. Вы можете обновить ваш IBM аккаунт в любое время.

Вся введенная информация защищена.

Выберите имя, которое будет отображаться на экране



При первом входе в developerWorks для Вас будет создан профиль и Вам нужно будет выбрать Отображаемое имя. Оно будет выводиться рядом с контентом, опубликованным Вами в developerWorks.

Отображаемое имя должно иметь длину от 3 символов до 31 символа. Ваше Имя в системе должно быть уникальным. В качестве имени по соображениям приватности нельзя использовать контактный e-mail.

Обязательные поля отмечены звездочкой (*).

(Отображаемое имя должно иметь длину от 3 символов до 31 символа.)

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Вся введенная информация защищена.


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=40
Zone=Технология Java
ArticleID=966736
ArticleTitle=Java.next: Карринг и частичное применение
publish-date=03252014