Freigeben über


Функциональный и императивный

А какой код, собственно, является императивным? Как ни странно, это не самый тривиальный вопрос. "Императивный" часто противопоставляется "функциональному", когда формально эти понятия не связаны. Правда в том, что функциональный код по идее не является императивным, но, согласитесь, что подобная формулировка не слишком хорошо подходит для определения.

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

И как только мы начинаем понимать "декларативность" языка как чисто количественную, если можно так выразиться, характеристику, все становится предельно просто. Что мы, собственно, хотим сказать, утверждая, что язык Foo декларативен? Какую такую его особенность мы пытаемся описать?

Возьмем для примера простейшую задачу. Нам нужно выбрать из массива все отрицательные числа. Вот как, к примеру, ее можно решить на C#: 

 var list = new List<int>();
var i = 0;

START:
    if (i == arr.Length)
        goto FINISH; 
    var e = arr[i];

    if (e < 0)
        list.Add(e);

    i++;
    goto START;
FINISH:

Что мы можем сказать про этот код? Ну кроме того, что он вряд ли прошел бы у вас code review. Мы здесь видим достаточно низкоуровневое решение поставленной задачи (даже несмотря на то, что мы не работаем напрямую с памятью и используем динамический типизированный массив, List<T>, для представления всех элементов исходной последовательности, которые удовлетворяют нашему условию).

В целом понятно, кого мы тут можем винить в "низкоуровневости". Конечно же, это печально известный goto, который недалеко ушел от соответствующих конструкций ассемблера - Br, Brtrue и пр. В результате этот листинг кода демонстрирует нам множество деталей реализации, без которых, скажем так, мы вполне могли бы прожить. На входе у нас была простая и понятная бизнес-задача - к примеру, получить всех должников по кредиту. А что мы имеем на выходе?

Непорядок. Давайте попробуем улучшить нашу реализацию:

 var list = new List<int>();

for (var i = 0; i < arr.Length; i++) {
    var e = arr[i];

    if (e < 0)
        list.Add(e);
}

Так, это уже выглядит более привычно. Количество строчек кода заметно сократилось. А главное - читается этот пример гораздо проще, чем предыдущий, правда? Можно даже сказать - этот код более декларативен, чем предыдущий. А почему бы и нет? Вместо меток и условных переходов мы используем более, скажем так, наглядную конструкцию, которая прячет от нас все эти "детали реализации". Если вы скомпилируете этот код и посмотрите сгенерированный MSIL, то увидите, что все наши goto по-прежнему там, только теперь их созданием занимаемся не мы сами, а компилятор. И все это благодаря простой и понятной конструкции for.

Но знаете что, все-таки на самом деле этот код совсем не декларативный. Да, мы избавились от явного goto, но то, что мы получили, не слишком сильно от него отличается. Мы заводим переменную-счетчик для цикла, проверяем условие на выход из цикла, получаем элемент по индексу. Какая же это декларация? Это же явно заданная последовательность действий. И виноват во всем этом все тот же for.

Давайте сделаем наш код еще лучше:

 var list = new List<int>();

foreach (var e in arr)
    if (e < 0)
        list.Add(e);

Вот это уже другое дело! Посмотрите мне в глаза и скажите, что этот код не декларативен! Особенно, если сравнить его с самым первым примером. Да он ведь даже читается практически как предложение на чистейшем английском языке - "для каждого е в arr...". А еще этот код более универсален. Мы больше не получаем элементы по индексу и можем работать практически с любой перебираемой последовательностью, которая реализует специальную абстракцию - ай-перебиратель. Ну, впрочем, вы и сами все знаете.

Ну так что, это все? Мы уже достигли вершин декларативности? Конечно же, нет. Вообще наш пример с foreach является скорее ярким примером императивного подхода. Да, мы избавились от низкоуровневых деталей работы с массивом, но у нас по-прежнему куча последовательных инструкций - перебрать все элементы, сравнить каждый элемент с нулем и т.д. и т.п.

Как сделать этот код еще лучше? Легко:

 var list = arr.Where(e => e < 0);

А вот это уже больше похоже на настоящую декларативную программу. Не нужно никаких циклов - мы всего лишь комбинируем две функции и получаем нужный результат. (Вы можете сказать, что пример этот не совсем "честный" - ведь мы используем функцию Where из Linq. Но никакой проблемы тут нет. Конструкцию foreach тоже ведь кто-то должен реализовать - причем совершенно неважно, реализована ли она на уровне компилятора или библиотеки).

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

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

Программа на функциональном языке не оперирует данными - это удел более низкоуровневых языков, таких как C# (хотя и на C#, как вы видите, многие приемы функционального программирования вполне реализуемы). Вместо данных, на функциональном языке мы совершаем операции над функциями.

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

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

 var arr = new int[] { 1, 2, 3, 4, 5 };
arr.Aggregate(0, (x,y) => x + y);

Нужно получить максимальный элемент в последовательности? Снова Aggregate:

 var res = arr.Aggregate((x,y) => x > y ? x : y);

Мы просто думаем в категориях функций, а не данных, что возможно делать даже на C#, хотя этот язык с трудом можно назвать функциональным. Функция становится для нас той самой абстракцией, которая не просто прячет от нас детали реализации, а вообще изолирует нас от работы с данными. Иначе говоря, мы описываем данные через функции.

Разумеется, на настоящем функциональном языке делать это немного удобнее. Вот как, к примеру, выглядит функция суммирования на Хаскеле (foldl - аналог Aggregate):

 xs = [1..5]
sum = foldl (+) 0
res = sum xs

И практически так же на Ela:

 let xs = [1..5]
let sum = foldl (+) 0
let res = sum xs

Сейчас мне полагается убеждать вас, что этот код декларативный, наглядный и прочая и прочая. И очень часто на этом этапе знакомство с функциональными языками и заканчивается. Когда я показал этот код своему коллеге, он надолго задумался, а потом задал мне один (ну, честно говоря, далеко не один, но этот был самый цензурный) вопрос - а сколько аргументов принимает foldl? Правильный ответ - всего лишь один.

Но об этом - в следующий раз.