Несколько месяцев назад я пытался анализировать данные временных рядов из журналов splunk. После некоторого головокружения (помните, что дергать за волосы — это не мой вариант) и значительного проклятия я отправился на поиски дружественного для разработчиков способа обработки потоков событий и нашел Римана. Риманн использует Clojure — язык на основе Lisp, который работает на JVM и позволяет разработчикам легко фильтровать, комбинировать и выполнять другие операции с потоками событий, используя концепции функционального программирования. В итоге я провел большую часть декабря, пытаясь понять концепции функционального программирования и то, как они могут помочь разработчикам работать более продуктивно.

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

В настоящее время я также хочу отметить и поблагодарить @redzedi и @ptrthomas, которые помогли мне в этом путешествии и помогли сделать эту статью лучше.

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

Рассмотрим, например, задачу: Дан список чисел, найти сумму квадратов всех нечетных чисел. Классическое императивное решение выглядит так

var data = [1, 2, 3, 4, 5, 6];
var i;
var out = 0;
for (i=0; i<data.length; i++){
   if (data[i] % 2 !== 0 ) {
     out += data[i]*data[i];
   }
}

Более функциональный подход приводит к

var data = [1, 2, 3, 4, 5, 6];
var isOdd = function(x) { return (x%2 !== 0); };
var square = function(x) { return x*x; };
var add = function(x,y) { return x+y; }
var out = data.filter(isOdd).map(square).reduce(add);

При первом подходе мне приходится иметь дело со всевозможными низкоуровневыми деталями — начальными значениями, сторожевыми условиями и т. д. Код также кажется монолитным — кажется, нет четкого разделения между тремя частями постановки задачи (найти числа, которые соответствовать критериям, сделать что-то с каждым, сделать что-то с коллекцией).

Мне второе решение кажется более «естественным» — в том смысле, что оно больше соответствует тому, как я мысленно разложил исходную задачу на более мелкие модули, решал каждый из них и собирал их вместе для общего решения. Эта способность модульности и компоновки кажется огромным преимуществом.

При втором подходе я чувствую, что могу мыслить на более высоком уровне абстракции — «что», если хотите, тогда как при первом подходе я чувствую, что меня затягивают в детали реализации — «как». Тем не менее, второй подход кажется многим программистам немного чуждым. Возможно, это просто потому, что мы делали это по-другому в течение двух десятилетий. В конце концов, большинство из нас изучали Java, потому что это то, что изучали все остальные… :-)

Это, конечно, тривиальный пример, но он вызывает вопрос — если бы вы были ответственным за поддержку этой кодовой базы, с какой из них вы бы предпочли иметь дело?

Лисп-подобные языки программирования, такие как Scheme и Clojure, выводят эту концепцию модульности и композиции на совершенно новый уровень. На фундаментальном уровне скромная «пара» является основой всех структур данных. Возможны только три операции

  • минусы: (для конструкции), который принимает два аргумента и возвращает составную структуру данных, называемую «парой».
  • car : это возвращает первый элемент этой пары
  • cdr : возвращает второй элемент этой пары

Например:

1 ]=> (define x (cons 10 “dog”))
1 ]=> (car x) ;Value: 10
1 ]=> (cdr x) ;Value 14: “dog”

Можно показать (см. книгу «Структура и интерпретация компьютерных программ»), что все структуры данных могут быть построены с использованием этих трех примитивов.

Решение вышеуказанной проблемы может быть записано на схеме как

(define data (list 1 2 3 4 5 6))
(define (square x) (* x x))
(fold-right + 0 (map square (filter odd? data)))

Поначалу это определенно выглядит странно (поэтому Лисп иногда называют «много безумно глупых скобок»), но на самом деле это довольно просто. Все выражения Лиспа имеют одинаковый формат — открывающая скобка, за которой следует операция, за которой следует один или несколько операндов. Так что что-то вроде foo(a,b) в C/Java и т.д. будет записано как (foo a b ) в Lisp. Это имеет некоторые интересные последствия — во-первых, нет понятия приоритета оператора. Также это приводит к очень краткому коду.

В этом примере мы использовали функции отображения, фильтрации и сокращения, которые также предоставляются Scheme. Как мы можем их реализовать? В JavaScript мы могли бы написать фильтр как…

var myfilter = function (f, data) {
  var out = [];
  var i=0;
  for(i=0; i<data.length; i++) {
    if( f(data[i]) === true ) out.push(data[i]);
  }
  return out;
};

Обратите внимание, что этот код использует изменяемые структуры данных, такие как «out» и «i». С другой стороны, функциональные языки программирования поощряют использование неизменяемых структур данных. Рекурсия часто используется там, где императивные языки программирования использовали бы циклы. Вот одна из реализаций фильтра на схеме.

(define (my-filter f items)
  (cond
    ( (null? items) items)
    ( (f (car items)) (cons (car items) (my-filter f (cdr items))))
    ( else (my-filter f (cdr items)))
 )
)

Обратите внимание на использование функций высшего порядка и тот факт, что данные никогда не меняют своего состояния (т. е. неизменны). Если «исправить» круглые скобки, код будет выглядеть так:

my-filter( f, items ) {
   if( items is null ) return items
   x = first(items) // the first item from the list
   restOfListFiltered = my-filter(f, rest(items));
    if( f(x) == true ) return append(x, restOfListFiltered)
    else return(restOfListFiltered)
}

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

Рассмотрим следующую задачу: Найдите первое число Фибоначчи, большее 100. В Clojure мы можем написать решение для этого как

; inefficient code to generate the nth Fibonacci number
(defn fibonacci 
   [n] 
   (if (< n 2) 
     n 
     (+ (fibonacci (- n 1)) (fibonacci (- n 2))))) 
;generate the first 10 fibonacci numbers
(map fibonacci (range 10)) 
; find the first fibonacci number > 100
(first (filter #(> % 100) (map fibonacci (range 1000))))

Я не планирую подробно описывать Clojure в этом посте, но сейчас достаточно знать, что Clojure — это диалект Lisp. Давайте разберем код выше…

Функция диапазона генерирует список чисел (0 1 2 … 999). Функция map применяет функцию fibonnaci к каждому элементу этой коллекции и возвращает ленивую последовательность чисел. Затем эта последовательность фильтруется, чтобы получить все числа больше 100, и, наконец, мы извлекаем первый элемент этого списка. Обратите внимание, что причудливая #(›% 100) — это не что иное, как анонимная функция, которая возвращает true, когда входной параметр больше сотни.

Поскольку Clojure поддерживает ленивые вычисления, он на самом деле никогда не вычисляет все 1000 чисел Фибоначчи, а это означает, что этот код выполняется очень быстро. Это также делает Clojure особенно подходящим для обработки потоков событий, которые на самом деле представляют собой не что иное, как бесконечные последовательности данных.

В заключении …

Языки функционального программирования, такие как функции без сохранения состояния, функции высшего порядка и ленивые вычисления, могут использоваться для создания высокомодульного и компонуемого кода и в основном помогают нам быть более продуктивными. Хотя верно то, что некоторые языки, такие как Clojure, лучше подходят для функционального программирования, чем другие, функциональное программирование возможно на большинстве языков — даже на Java. Тот факт, что концепции функционального программирования все чаще внедряются в основные экосистемы, такие как Java, свидетельствует о надежде, которую они порождают.

Сказав это, _IS_ трудно изменить привычки, которые были сформированы за десятилетие или более - в конце концов, как мы можем писать код без циклов for? Но при некоторой самоотверженности и самодисциплине со временем можно измениться и выработать новые привычки — держу пари, что вы больше не используете «ПЕРЕХОД». В качестве первого шага к развитию мышления функционального программирования — в следующий раз, когда вы будете копаться в коде, просто спросите себя — «могу ли я переписать это, используя карту, фильтр или сокращение?». Вы можете быть удивлены.