Функции высшего порядка
В этом посте мы продолжим знакомство с функциональным программированием и начнем рассмотрение базовых концепций, которые обуславливают его сильные стороны и являются причиной его недостатков. Сегодня поговорим о функциях высшего порядка.
Функции высшего порядка (high-order functions)
Функцией высшего порядка (ФВП) называется функция, которая принимает в качестве параметра другую функцию или возвращает функцию в качестве результата. Например, ранее мы рассматривали реализацию быстрой сортировки на языке Haskell. В ней шаг рекурсии выражался следующей строчкой: qsort (x:xs) = qsort(filter (< x) xs) ++ [x] ++ qsort(filter (>= x) xs). В этом коде функция filter в качестве параметра принимает функцию - критерий фильтрации. Поэтому filter является функцией высшего порядка. Другими примерами типичных ФВП являются отображения, аккумуляторы и свертки.
Ценностью ФВП является то, что они делают возможной функциональную композицию, которая позволяет выделить независимые части из подпрограмм, которые в императивном программировании являются минимальной единицей структуризации кода. Благодаря этому: 1) общую структуру конкретной подпрограммы становится легче анализировать, 2) каждый из меньших фрагментов становится легче охватить мысленным взглядом, 3) существуют способы повторно использовать фрагменты между подпрограммами.
Практический пример
В классической статье "Why Functional Programming Matters" Джон Хьюз приводит ряд примеров, которые показывают мощь функциональной композиции. Чтобы дополнить картину, мы приведем еще одну иллюстрацию - сравним императивную и функциональную реализации обработки абстрактного синтаксического дерева. Для краткости примера рассмотрим простой AST, состоящий из нодов Literal (объявление литерала) и Apply (применение функции).
Решение, предлагаемое на MSDN, основывается на паттерне Visitor. В рамках этого паттерна мы реализуем класс (далее будем называть его “визитор”), который для каждого типа нода содержит по одному методу, в котором задана логика обработки нода и последующего обхода его детей. Иллюстрацией этого подхода является базовый класс для визиторов (реализация упрощена в целях краткости):
public abstract class AbstractVisitor
{
protected Expression Visit(Expression exp)
{
switch(exp.NodeType)
{
case ExpressionType.Literal:
return VisitLiteral((LiteralExpression)exp);
case ExpressionType.Apply:
return VisitApply((ApplyExpression)exp);
default:
throw new ArgumentException(String.Format("Unexpected node '{0}' of type '{1}'",
exp, exp.NodeType));
}
}
protected virtual LiteralExpression VisitLiteral(LiteralExpression le)
{
return le;
}
protected virtual ApplyExpression VisitApply(ApplyExpression ae)
{
var args = ae.Args.Select(arg => Visit(arg));
return Expression.Apply(ae.Function, args);
}
}
Функциональный подход к решению задачи представляет собой катаморфизм - ФВП, которая содержит логику обхода АСТ и принимает в качестве параметра N различных функций, реализующих обработку соответствующих типов нодов. Рассмотрим реализацию катаморфизма на языке F# (если вы не знакомы с F#, то в объеме, необходимом для понимания кода, приведенного ниже, его можно освоить, прочитав ознакомительный пост; реализация предельно упрощена в целях краткости, о ее возможных усовершенствованиях можно почитать в серии статей Брайана МакНамары).
let fold ast literalF applyF =
let rec Iter ast =
match ast with
| Literal(value) -> literalF value
| Apply(name, args) -> applyF name (Seq.map Iter args)
Iter ast
Анализ примера
Сразу же можно заметить, что конкретному визитору, который перекрывает некоторый метод VisitXXX, необходимо будет воспроизвести логику обхода детей обрабатываемого узла, в то время как при использовании функционального подхода работа с нодом четко разделяется на логику fold (обход) и логику literalF/applyF (обработки). Здесь мы наблюдаем функциональную композицию в действии - монолитный для императивной версии код мы смогли разделить на две части, каждая из которых может использоваться отдельно.
Рассматриваемый пример позволяет сделать еще одно наблюдение, аналогичное предыдущему - сравнить объектную и функциональную композицию. На первый взгляд подходы идентичны - абстрактному классу соответствует fold, а методам VisitXXX соответствуют функции-параметры. Но при ближайшем рассмотрении можно заметить, что в отличие от методов, жестко связанных с классом, в котором они объявлены, функции обработки literalF и applyF никак не привязаны к fold. Например, в объектно-ориентированной модели не получится скомбинировать логику двух визиторов без внесения дублирования.
Кроме того, в одной из последних статей серии Брайана МакНамары про катаморфизмы можно увидеть еще один аспект функциональной декомпозиции – благодаря тому, что катаморфизм становится отложенным (lazy), у нас появляется возможность извне обрывать рекурсивное углубление в структуру данных, то есть вместо логики обхода (fold) и N обработчиков (literalF, applyF) наш алгоритм декомпозируется на обходчик, N контроллеров и N обработчиков. Конечно же, такие возможности нужны не всегда, но отметьте, насколько мелкозернистой в этом случае оказывается функциональная декомпозиция по сравнению с декомпозицией, предоставляемой ИП и ООП.
Заключение
Функции высшего порядка - ценная абстракция функционального программирования, благодаря которой программу можно с высокой точностью разделить на независимые фрагменты. На практике это приводит к более быстрому написанию, лучшей читаемости и сопровождаемости кода.
В контексте параллельного программирования функциональная композиция полезна тем, что в последовательной императивной программе она выделяет структурные части, которые можно автоматически проанализировать и, возможно, выполнить параллельно.
Завершаясь, стоит отметить, что доступны ФВП в любом языке, в котором есть понятие "указатель на функцию", поэтому теоретически преимуществами функциональной композиции можно воспользоваться практически всегда. Другое дело, что для удобства использования ФВП необходима встроенная в язык поддержка как минимум четырех концепций - замыканий, полиморфизма типов, выведения типов и удобной формы записи функций. Эти возможности лишь недавно пришли в популярные языки программирования - например, C# получил их только в третьей версии, а в Java на момент написания статьи их нет.
Материалы для чтения по темам следующих постов
Темой следующего поста о базовых концепциях функционального программирования будет предмет священной войны теоретиков - чистота функций. Мы рассмотрим необходимость изменяемого состояния в программах, возможности по его инкапсуляции, а также преимущества и недостатки альтернативных взглядов на проблему. Кроме документа по ссылке выше рекомендуем к прочтению:
1. "Referential transparency", from Wikipedia, the free encyclopedia
2. "Verifiable Functional Purity in Java", Matthew Finifter et al., 2008
3. "Code Contracts #5: Method purity", Matthias Jauernig, 2009
Для ценителей функционального программирования у нас есть еще одна отличная статья - лекция Джона Бэкуса "Can Programming Be Liberated from the von Neumann Style? A Functional Style and Its Algebra of Programs", прочитанная им на получении награды ACM Turing Award.