Неправильное введение в функциональное программирование

Автор: Воронков Василий Владимирович
Опубликовано: 03.04.2013
Версия текста: 1.1
Зачем все это нужно?
Функциональный и императивный
Каррирование и частичное применение
Еще немного о каррированных функциях
Чистые функции
Алгебраические типы
ООП как путь к динамической типизации
Полиморфные варианты
Вместо заключения
Список литературы

Зачем все это нужно?

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

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

Функциональное программирование, тем не менее, становится достаточно модным. И хотя CRM системы пока что обычно не пишутся на каком-нибудь Хаскелле, функциональное программирование начинает потихоньку проникать и в мейнстримные языки. Наверняка вы много раз слышали такие термины как «декларативный», «каррирование», «частичное применение», «чистые функции», «алгебраические типы данных» и проч. и проч. Что же это все означает? Не скрою, что многие объяснения, которые попадались мне в статьях и блогах, выглядели весьма путано и туманно. Эту ситуацию, разумеется, нужно исправить.

Итак, как вы можете догадаться, в данном «введении» я не буду рассказывать об истории функциональных языков программирования. Я не буду писать о лямбда-исчислении и комбинаторике. Я даже не буду убеждать читателя в том, что функциональное программирование – это полезно и важно. Наверняка вы уже неоднократно обо всем этом читали. А если и нет, то есть множество других статей, которые вам об этом расскажут. У меня в данном случае несколько иная задача. Я постараюсь действительно ответить на некоторые вопросы, которые могли остаться у вас после прочтения других «введений». Это, конечно, не слишком соответствует традициям – отсюда и подобное название у этой статьи.

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

По традиции начнем с простого.

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

Термин «императивный» должен противопоставляться «декларативному». И вот мы имеем еще один термин с весьма расплывчатым определением. Во-первых, «декларативный» далеко не обязательно означает «функциональный». Более того, найти какой-либо критерий, по которому можно однозначно разделять код на императивный и декларативный, не так просто, как кажется на первый взгляд. Казалось бы, зачем мудрить? «Императивный» предполагает, что мы описываем, как должна быть выполнена задача, фиксируем конкретную цепочку алгоритма, тогда как «декларативный» сводится лишь к декларации наших намерений, к описанию того, что мы хотим получить. Все хорошо в теории, но на практике далеко не всегда бывает понятно, с какого именно момента код перестает быть императивным. Да и можно ли вообще писать декларативный код на каком-нибудь C#?

Рассмотрим это на примере. Нам нужно выбрать из массива все отрицательные числа. Вот как, к примеру, можно сделать это на 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, который недалеко ушел от соответствующих конструкций IL ассемблера – 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 = newint[] { 1, 2, 3, 4, 5 };
arr.Aggregate(0, (x,y) => x + y);

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

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

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

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

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

На всякий случай, foldl – это аналог Aggregate.

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

Каррирование и частичное применение

Наверняка вы уже слышали такие термины, как «каррирование» и «частичное применение». Термины эти используются достаточно широко, однако, несмотря на это, существует определенная путаница, из-за которой некоторые даже считают, что каррирование и частичное применение – это одно и то же. Кто в этом виноват? Наверное, авторы других статей. А на кого же еще свалить?

Но я, разумеется, не такой.

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

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

Как следует понимать выражение вида:

sin x * 2

Или, еще хуже:

sin x + y * 2

Что конкретно выступает в качестве аргумента для функции sin?

Но не все так страшно. Очевидно, что для операции вызова функции попросту требуется приоритет. Какой же приоритет выбрать? Если взять низкий приоритет, то не всегда будет понятно, что же в действительности является аргументом – по сути, аргумент будет представлять собой все выражение, следующее после функции, до самого, так сказать, края листа. Это, очевидно, не очень удобно, да и как-то не способствует читабельности. Фактически, нам придется постоянно заключать вызов функции в скобки. Получается, в одном месте от скобок избавились, а в другом – их придется писать постоянно, т.е. вместо sin(x) будет (sin x). Невелика заслуга.

А что, если присвоить вызову функции самый высокий приоритет? Тогда никаких дополнительных скобок не требуется, и выражение вида sin x * 2 будет полностью эквивалетно sin(x) * 2.

Годится. Идем дальше.

Что мы еще можем сказать о нашей операции вызова функции? Функция всегда идет первой, а аргумент следует сразу ней. Логично, что оператор вызова функции должен быть левоассоциативным (как, например, большинство операторов в C#) и, соответственно, исполняться будет слева направо. А теперь внимание. Кто-то написал на нашем языке такой код:

sum 2 2

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

Вовсе нет. Так как вызов функции левоассоциативный, то выражение вида sum 2 2 полностью эквивалентно (sum 2) 2. Т.е. это вовсе не вызов функции с двумя аргументами, как может показаться на первый взгляд. Это два последовательных вызова функции. Мы вызываем sum с аргументом 2 и считаем, что результатом данного вызова является другая функция, которую мы вызываем еще раз. Аналогичный код на C# выглядел бы так:

sum(2)(2)

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

Итак, начали мы с того, что стали разрабатывать синтаксис для «бесскобочного» вызова функций, а закончили тем, что придумали, как нам обходиться без функций, принимающих более одного аргумента. Все просто – мы будем создавать функции, которые возвращают другие функции! Сделать это несложно и на C#:

Func<int,int> sum(int x)
{
    return y => x + y;
}

Как вы знаете, лямбды в C# являются замыканиями – внутри тела лямбды можно обращаться к переменным, объявленным в родительской функции, что и происходит в данном случае. Благодаря этому трюку, объявление функции sum не представляет особых проблем.

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

Func<int,int> sum(int x)
{
    return z => y => x + y + z;
}

Разумеется, на языке с более ловким выводом типов (или на языке с динамической типизацией) аналогичное объявление будет выглядеть несколько лаконичнее – но все равно далеко от идеала:

sum x = \z -> \y -> x + y + z

Но ведь на самом деле это не более чем проблема синтаксиса. Почему бы не добавить немного «сахара»? В сущности, мы можем объявлять функции, используя тот же синтаксис, что и при вызове:

sum x y z = x + y + z

А компилятор сам разберется, что ему надо делать (фактически, оба наших определения sum полностью равносильны, просто второе избавляет нас от необходимости вручную описывать замыкания).

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

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

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

Представьте, что у нас есть функция sum вида \x -> y\ -> x + y. Что произойдет, если при вызове этой функции мы укажем лишь один аргумент? Например, вот так: sum 5. А произойдет следующее – мы как бы создадим новую функцию «на лету», которая умеет складывать числа с 5. Конечно, на основе примера с суммированием оценить преимущества этой техники непросто, однако все становится достаточно очевидно, стоит нам взять чуть более сложную функцию – например, foldl.

Функция foldl – это широко распространенная функция высшего порядка, аналоги которой есть в стандартных библиотеках практически всех функциональных языков. Есть она даже в C# – под видом Aggregate. Данная функция принимает список, первоначальное значение для аккумулятора и другую функцию, которую она как бы «обворачивает» вокруг всех элементов списка (отсюда и название – fold). Функция foldl является такой важной, потому что через нее можно выразить множество других операций. Мы уже коснулись этого, когда рассматривали различные варианты использования Aggregate.

Попробуем теперь повторить то же самое на Haskell. Для начала – суммирование всех элементов списка, которое вы уже видели:

sum = foldl (+) 0

Здесь все просто – операторы в Haskell (как и во многих других функциональных языках) также являются функциями, поэтому нам нет нужды отдельно описывать операцию сложения, ведь это уже сделали до нас. Поэтому просто передаем в качестве первого аргумента функцию (+). Второй аргумент – первоначальное значениие аккумулятора. У нас это 0. А третий аргумент, собственно, список, элементы которого нужно сложить, мы «забыли». Что получается в итоге? Новая функция, принимающая список и складывающая его элементы.

Попробуем теперь описать функции поиска максимального элемента. Писать ее «в лоб» не очень правильно. Ведь эта функция является частным случаем более общей функции – поиска элемента по условию. Поэтому сначала опишем ее:

elemBy p (x:xs) = foldl (\x y -> if p x y then x else y) x xs

Здесь мы принимаем предикат и список. Странное выражение (x:xs) – это паттерн, «разбивающий» наш список на первый элемент (голову) и все оставшиеся элементы (хвост). Далее все достаточно очевидно. Теперь с помощью elemBy мы можем реализовать функцию поиска максимального элемента так:

maximum = elemBy (>)

А минимального – так:

minimum = elemBy (<)

Не пугайтесь, если какие-либо примеры вам не до конца понятны – все же изучение Хаскелла в рамки данной статьи не входит. Главное, чтобы был понятен общий принцип.

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

nat = filter (>0) --выбираем все натуральные числа

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

flip f x y = f y x

Мы принимаем функцию и два ее аргумента и вызываем эту функцию, передавая аргументы в обратном порядке. А вот пример использования flip:

reverse xs = foldl (flip (:)) [] xs

Мы создали функцию, которая «переворачивает» элементы списка в обратном порядке на основе foldl. Для конструирования списка мы используем стандартную функцию-конструктор списков (:). Данная функция в качестве первого аргумента принимает элемент, а в качестве второго – список, в голову которого этот элемент добавляется. Здесь же нам нужен обратный порядок, чего мы и добиваемся с помощью flip.

Вот, собственно, и весь секрет.

Таким образом, каррирование – это преобразование функции вида (a -> b) -> c в функцию вида a->b->c или, говоря человеческим языком, обычной, принимающей несколько параметров, функции в такую, которая принимает параметры по одному (и представляет собой цепочку вложенных функций). В большинстве функциональных языков все функции, как правило, уже каррированные, т.е. несмотря на видимую простоту своих объявлений, представляют собой этакие цепочки замыканий. А частичное применение – это вызов фукнции, в котором мы указываем лишь часть ее аргументов. И каррирование представляет собой одну из возможных технологий, с помощью которых мы можем получить механизм для частичного применения.

Еще немного о каррированных функциях

Вообще, каррированные функции – такая интересная штука, что совсем не хочется с ними быстро расставаться.

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

К примеру, сколько аргументов у такой функции:

      out
      "Hello"
      //Вывод:
      //Hello
      out
      "First"
      "Second"
      "Third"
      //Вывод:
      //First
      //Second
      //Third
    

Но вы наверняка уже знаете ответ – аргумент всего лишь один. А вот как функция out может быть определена, к примеру, на Ela:

open console
out x = writen x `seq` out

writen – это обычная функция вывода в консоль, такая же, как Console.WriteLine. Функция seq представляет собой так называемый sequencing operator. Ближайшим его аналогом в C# является точка с запятой. Функция seq, применяемая как оператор (для этого она заключается в апострофы), исполняет сначала левое выражение, игнорирует вычисленное при этом значение, затем исполняет правое выражение и возвращает результат его вычисления. В итоге мы описали функцию, которая принимает один аргумент, выводит его на консоль, а потом возвращает саму себя, в результате чего работать с этой функцией можно так, как если бы она принимала бесконечное число аргументов.

Или возьмем для примера другой язык – F#. В стандартной библиотеке F# есть такая функция printfn. Вот как она работает:

printfn "Hello, %s!""world"//Вывод://Hello, world!
printfn "%d+%d=%d" 2 2 (2+2)
//Вывод://2+2=4

Опять же, на первый взгляд кажется, что эта функция принимает неограниченное число параметров – так же, как и одна из версий Console.WriteLine, которая в качестве последнего параметра принимает массив аргументов с модификатором params. Но нет, printfn принимает всего лишь один аргумент.

Несмотря на то, что аргумент этот выглядит как строка, он в действительности имеет тип TextWriterFormat<'T>. Функция printfn типизируется в зависимости от значения этого аргумента. В первом случае в формате для вывода мы указали всего лишь один аргумент – и в результате printfn возвращает функцию для одного аргумента, которую мы тут же и вызываем в нашем примере. Во втором случае нам требуется аж три аргумента – и printfn возвращает функцию для трех аргументов.

Более того, printfn контролирует типы еще на этапе компиляции. В первом случае мы указали, что нам требуется строка (%s) – и получили функцию, которая принимает строку. Во втором случае нам потребовались три числовых типа (%d) – и мы получили функцию, принимающую три целые числа. Попробуйте вызвать ее с параметрами другого типа – и получите ошибку времени компиляции. Удобно, правда?

Фактически у нас получается, что тип возвращаемого значения у функции зависит от типа ее аргумента.

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

fun (x::xs) = fun' x xs
                where fun' a [] = \y -> y + a
                      fun' a (x::xs) = \y -> fun' (y + a + x) xs

Полученная в результате вызова fun функция суммирует элементы списка с переданными в нее аргументами и вычисляет общую сумму:

f = fun [1,2,3]
f 1 2 3
//Вывод://12

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

Возьмем чуть более практичный пример. Скажем, у нас есть функция openConnection, которая принимает название используемого протокола и номер порта. При этом, однако, номер порта не требуется нам, если в качестве протокола используется HTTP (в таком случае всегда используется 80-ый порт). Думаете, что для реализации такой функции нам пригодились бы необязательные параметры из C# 4.0? Мы можем прекрасно обойтись и без них:

openConnection p | p == "http" = create p 80
                 | else        = \port -> create p port
                 where create name port = {protocol=name,port=port}

А теперь вызовем эту функцию:

openConnection "tcp" 244
//Вывод//{protocol=tcp,port=244}
И для HTTP:
openConnection "http"//Вывод//{protocol=http,port=80}

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

Чистые функции

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

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

Ну и приведем для убедительности яркий пример чистой функции на Хаскеле:

add x y = x + y

Казалось бы, все просто. Но я не раз замечал, что существует некоторая путаница с тем, какие именно функции следует считать чистыми.

Прежде всего, следует понимать, что чистые функции никакого отношения к программированию в функциональном стиле не имеют. К примеру, является ли такая функция чистой:

      int sum(int[] arr)
{
  int res = 0;

  for (int i = 0; i < arr.Length; i++)
    res += arr[i];

  return res;
}

Правильный ответ – да, она такая же чистая, как и ее аналог на Хаскелле:

sum [] = 0
sum (x:xs) = x + sum xs

Да, наша реализация на C# написана в императивном стиле, более того, она всерьез «налегает» на изменяемые переменные – но все эти переменные локальные для функции, мы не изменяем никакого внешнего состояния и не зависим от него, мы всегда возвращаем один и тот же результат для тех же аргументов.

И тут возникает второй нюанс. Давайте возьмем для примера функцию filter:

filter _ [] = []
filter p (x:xs) | p x       = x : filter p xs
                | otherwise = filter p xs

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

Так почему же мы считаем функцию filter чистой? Да и вообще – чистая ли она?

Ответ прост. Мы считаем ее чистой, потому что списки в функциональных языках сравниваются не ссылочно (как, к примеру, массивы в C#), а структурно. Поэтому результаты двух вызовов этой функции эквивалентны, что несложно проверить в GHCi:

Prelude>filter (>5) [1..10] == filter (>5) [1..10]
True

Скажете, мы пошли на хитрость? И да, и нет. Так как список является неизменяемой структурой данных, то мы имеем полное право применять к нему те же правила при сравнении, что и, к примеру, для целых чисел. И с чистой душой игнорировать такие низкоуровневые детали, как размещение списка в памяти. Ведь с точки зрения пользователя два списка с одинаковыми элементами – это по сути один и тот же список.

А как быть с изменяемыми структурами данных? Попробуем написать отдаленный аналог функции filter на С#:

List<int> filter(Func<int,bool> p, List<int> list)
{
  var newList = new List<int>();

  foreach (var e in list)
    if (p(e))
      newList.Add(e);

  return newList;
}

Является ли эта функция чистой? Боюсь, что на сей раз придется ответить «нет». List<int>, вопреки своему названию, представляет собой изменяемый массив, поэтому считать два экземпляра List<int> с одинаковым набором элементов одним и тем же «списком» мы уже не можем. Почему? Только ли потому что в C# для List<int> или обычных массивов структурное сравнение не используется по умолчанию, а сравниваются ссылки?

Вовсе нет. Давайте представим, что массивы в C# сравниваются именно структурно. Таким образом, два массива с одинаковым набором элементов будут считаться равными. И тогда мы сможем написать такой код:

      var arr1 = newint[] { 1,2,3 };
var arr2 = newint[] { 1,2,3 };

if (arr1 == arr2)
  Console.WriteLine("arr1 и arr2 это один и тот же массив.");

arr1[0] = 10;

if (arr1 != arr2)
  Console.WriteLine("arr1 и arr2 это разные массивы.");

В результате выведется:

arr1 и arr2 это один и тот же массив.
arr1 и arr2 это разные массивы.

Как же так? Мгновение назад массивы были одинаковыми, а теперь они разные?

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

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

Алгебраические типы

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

Но не все так печально. Хотя алгебраических типов в C# действительно нет, но зато есть другой тип данных, во многом с ними схожий. Этот тип называется "перечисление" (enumeration).

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

Давайте представим, что мы работаем на некую компанию, которая продает различные продукты. У компании огромная сеть магазинов, однако ассортимент реализуемых товаров достаточно ограничен. В итоге мы можем с легкостью составить перечень всех предлагаемых нами продуктов, используя простое перечисление вида:

      public
      enum Product
{
    Cellphone,
    Laptop
}

Наша задача – разработать приложение, через которое будет доступна статистика продаж: количество проданных телефонов и компьютеров.

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

Первая же идея, которая приходит в голову, – написать собственный тип данных (назовем его Product), который будет представлять собой тип продаваемого продукта.

Это кажется неплохим решением, но опять же – как только мы приступаем к реализации типа Product, нам неожиданно становится ясно, что и этот вариант решения нам не подходит. Ведь продукты наши совершенно разные и имеют различный набор атрибутов.

Ладно, пожалуй, я слишком драматизирую. Все, что нам нужно – использовать одну из корневых возможностей объектно-ориентированного программировния под названием наследование. И вот что у нас получается в итоге:

      public
      abstract
      class Product
{

}

publicsealedclass Cellphone : Product
{
    bool TouchScreen { get; set; }
}

publicsealedclass Laptop : Product
{
    double ScreenSize { get; set; }
}

(Не пытайтесь анализировать набор атрибутов – я их выбрал практически случайным образом).

Итак, мы почти завершили дизайн наших типов данных. Однако осталось одно требование, которому представленная выше иерархия продуктов по-прежнему не удовлетворяет. Как вы помните, по некой странной причине наша компания продает только ноутбуки и мобильные телефоны, и ничего, кроме этих двух продуктов. Более того, у нас есть, можно сказать, пожизненная гарантия того, что компания никогда не расширит ассортимент предлагаемых товаров. А следовательно, нам нужно убедиться, что никакие другие классы, кроме Laptop и Cellphone, не смогут наследоваться от Product.

И, после некоторых размышлений, мы приходим к следующему решению:

      public
      abstract
      class Product
{
    private Product() { }

    publicsealedclass CellphoneProduct : Product
    {
        publicbool TouchScreen { get; set; }
    }

    publicsealedclass LaptopProduct : Product
    {
        publicdouble ScreenSize { get; set; }
    }

    publicstatic LaptopProduct Laptop(double screenSize)
    {
        returnnew LaptopProduct { ScreenSize = screenSize };
    }

    publicstatic CellphoneProduct Cellphone(bool touchScreen)
    {
        returnnew CellphoneProduct { TouchScreen = touchScreen };
    }
}
ПРИМЕЧАНИЕ

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

Итак, что же у нас получилось. Вряд ли такой подход к дизайну классов в C# можно назвать типичным. С другой стороны – мы всего лишь используем стандартные средства ООП и некоторые дополнительные возможности, предоставляемые C#. Мы объявили классы CellphoneProduct и LaptopProduct как вложенные в родительский тип Product, так как вложенные классы могут обращаться к private-членам своих классов-контейнеров. Благодаря этому мы смогли описать у класса Product private-конструктор, что позволяет нам убедиться в том, что никто больше не будет наследником этого класса. Также мы пометили CellphoneProduct и LaptopProduct модификатором sealed, который делает классы «закрытыми».

Вот как мы теперь сможем использовать эти классы:

      var l = Product.Laptop(14.2);

if (l is Product.LaptopProduct) {
    ...
}
elseif (c is Product.CellphoneProduct) {
    ...
}

Я не случайно так акцентирую внимание на нашей иерархии продуктов. Ведь фактически мы только что написали на C# самый настоящий алгебраический тип. И ведь правда – у нас есть тип Product, который может быть или ноутбуком (LaptopProduct), или мобильным телефоном (CellphoneProduct) – и ничем, кроме этих двух. Говоря другими словами, тип Product представляет собой сумму продуктов LaptopProduct и CellphoneProduct. При этом мы искусственно ввели ряд ограничений, благодаря которым у нас появляются существенные отличия от классической объектно-ориентированной иерархии классов:

Хорошо, но у вас наверняка возникает вопрос – а чего мы, собственно, добились, введя эти странные ограничения?

ООП как путь к динамической типизации

Возможно, вам доводилось слышать такое утверждение о языке программирования Хаскелл – Хаскелл это полностью статически типизированный язык. Строго говоря, это, конечно же, не совсем так, но сейчас речь не об этом. Согласитесь, что это утверждение в каком-то плане интригует? Нам как бы намекают, что есть простые языки со статической типизацией (вроде C#), а есть такие особенные как Хаскелл, которые «полностью» статически типизированы.

Так что же означает эта полная статическая типизация?

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

      public
      abstract
      class Foo
{
  void DoSomething();
}

...

publicvoid CallFoo(Foo foo)
{
   foo.DoSomething();
}

Что мы можем сказать об этом коде (кроме того, что он демонстрирует фундаментальный механизм, на основе которого строится полиморфизм в ООП-языках)? Вернее, давайте зададим вопрос иначе – может ли данный код быть статически типизирован?

На первый взгляд ответ очевиден. Собственно, тут и типизировать-то нечего – все аннотации типов указаны, компилятор прекрасно знает, где и какие типы используются. Вы, к примеру, не сможете вызвать функцию CallFoo, передав в нее строку или целое число. А что мы можем передать в CallFoo?

И тут-то начинается самое интересное. Ведь стоит немного задуматься, как понимаешь, что мы в действительности понятия не имеем, экземпляр какого конкретного типа может быть передан в Foo. Этот тип может быть реализован вообще спустя несколько лет после того, как вы напишете CallFoo. Все, что нам известно об этом типе, это то, что он должен быть наследником класса Foo – причем не обязательно прямым, сойдет и «дальний родственник» – как говорится, седьмая вода на киселе.

Следствием всего этого является один простой факт – компилятор попросту не знает, какой конкретно метод под названием DoSomething будет вызван. Собственно, в нашем примере этот метод вообще не реализован. А определять, какая именно реализация DoSomething должна быть вызвана внутри CallFoo, будет уже среда исполнения. Проще говоря, конкретный тип аргумента foo станет нам известен только в рантайме. Так, секундочку, а чем там статическая типизация отличается от динамической?

Фактически, в объектно-ориентированном языке (таком как C#), при написании кода с использованием полиморфизма классов, компилятору доступна лишь часть информации о типе – известен общий тип, но не известен частный.

Что же должно происходить в языке с «полной» статической типизацией?

Как вы помните, в предыдущей части мы пытались понять, что же представляют собой алгебраические типы, и обнаружили немало общих черт между ними и обычным наследованием в объектно-ориентированных языках. Но и немало существенных отличий. Основное из них заключается в том, что алгебраический тип по сути представляет собой закрытую одноуровневую иерархию наследования – вы не можете «дописать» новый конструктор к существующему алгебраическому типу «извне» – как вы бесспорно можете создать наследника класса, переопределить его методы и при этом не менять уже написанный код, который с этим классом работает.

Таким образом, при использовании алгебраических типов конкретный тип всегда известен полностью, компилятор всегда знает, какая именно реализация DoSomething будет вызвана в тот или иной момент. А отсюда происходит и утверждение о «полной» статической типизации.

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

Алгебраические типы напрямую поддерживаются многими функциональными языками. Помимо Хаскелла, здесь можно упомянуть также и F#. Вот как наш пример из предыдущей части – с ноутбуками и мобильными телефонами – может быть переписан на F#:

      type Product = 
    | Laptop of double 
    | Cellphone ofboollet l = Laptop 14.2

let res = match l with
          | Laptop _    -> "We have a laptop"
          | Cellphone _ -> "We have a cellphone"

Как видите, кода нам пришлось написать куда меньше. Да и выглядит он куда проще, чем имитация алгебраического типа на C#, даже если вы не знакомы с ML-подобным синтаксисом.

На примере F# также очень удобно показать «закрытость» алгебраических типов. Как видите, в примере выше есть код, который проверяет экземпляр алгебраического типа и присваивает переменной res значение, в зависимости от того, с каким экземпляром мы имеем дело. Давайте попробуем добавить новую продукцию к типу Product, при этом код для обработки оставим без изменений:

      type Product = 
    | Laptop of double 
    | Cellphone ofbool
    | Monitor ofbool

А теперь попробуем скомпилировать нашу программу:

warning FS0025: Incomplete pattern matches on this 
expression. For example, the value 'Monitor (_)' may 
indicate a case not covered by the pattern(s).

Как видите, заботливый компилятор F# тут же предупреждает нас, что имеющийся код для обработки неполон и не учитывает наличие продукции Monitor. Удобно, правда? И к тому же весьма сильно отличается от того, к чему мы привыкли в объектно-ориентированных языках.

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

Полиморфные варианты

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

Помните пример с компанией, которая умеет продавать только мобильные телефоны и ноутбуки? Ну ради бога, разве у реальной компании будут такие нелепые ограничения? Скорее наоборот – сегодня мы продаем телефоны и ноутбуки, а завтра – предметы женского гардероба. Очевидно, что алгебраические типы в таком случае окажутся не слишком полезными.

На самом деле проблема невозможности расширения алгебраических типов не дает покоя лучшим умам уже много лет. Ей даже придумали название – expression problem. Ну, раз у проблемы даже есть название, то должно быть и какое-нибудь решение – ведь не может быть такого, что «лучшие умы» за столько лет так ничего и не придумали? И решение действительно есть.

Начнем как обычно издалека.

Есть такой язык программирования OCaml, дальний (впрочем, не такой уж и дальний) родственник F# (скажу по секрету – F# первоначально фактически и представлял собой версию OCaml под платформу .NET, но впоследствии их пути немного разошлись). OCaml, как и подобает функциональному языку из семейства ML, поддерживает «классические» алгебраические типы. Но в какой-то момент в OCaml появился и другой тип данных – с интригующим названием «полиморфный вариант».

Что же это такое? Давайте посмотрим на примере. Попробуем переписать код из предыдущей части на OCaml с использованием этих самых полиморфных вариантов:

      let l = `Laptop 14.2;;

let res = match l with
          | `Laptop _    -> "We have a laptop"
          | `Cellphone _ -> "We have a cellphone";;

На первый взгляд все очень похоже на F#, если не считать дополнительных «знаков препинания». Но погодите, а мы точно ничего не забыли? Где же объявление нашего алгебраического типа? (Ну или, как его, «полиморфного варианта»?). А в том-то и дело, что никакого объявления нет.

Отвечая на вопрос, каким образом сделать алгебраические типы расширяемыми, OCaml приходит к довольно-таки неожиданному решению. А давайте представим, что во всей нашей программе – да что там «программе», во всем мире! – есть лишь один-единственный алгебраический тип. Этот тип включает все возможные конструкторы – даже те, которые вам только предстоит придумать. По этой причине нет никакой необходимости заранее декларировать алгебраический тип – с полиморфными вариантами он как бы объявляется на ходу.

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

      let res = match l with
          | `Laptop _    -> "We have a laptop"
          | `Cellphone _ -> "We have a cellphone"
          | `Monitor _   -> "We have a monitor";;

Однако в вышеприведенном коде есть одна серьезная проблема. В случае с закрытыми алгебраическими типами нам всегда точно известно, какие конструкторы включает в себя тот или иной алгебраический тип. Соответственно, предыдущая версия кода по анализу продукта (написанная на F#) была полностью корректна и безопасна – ведь нам же известно, что компания не продает ничего, кроме ноутбуков и мобильных телефонов, более того, сам компилятор гарантирует нам это!

Здесь же ситуация в корне изменяется. Перечислить все конструкторы мы попросту не можем, а соответственно, код, приведенный выше, уже не так безопасен, как раньше. Что будет, если кто-нибудь вызовет его с вариантом `Tablet? Будет ошибка времени исполнения. Чтобы избежать этого, нам придется переделать этот код так:

      let res = match l with
          | `Laptop _    -> "We have a laptop"
          | `Cellphone _ -> "We have a cellphone"
          | `Monitor _   -> "We have a monitor"
          | _ -> "We have an unknown product";;

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

Вторая проблема явно проистекает из того факта, что варианты не требуют предварительного объявления. А раз объявление необязательно, то компилятор никак не сможет сам разобраться, «вводите» ли вы новый полиморфный вариант или же обращаетесь к уже «объявленному» ранее. А это приводит к таким вот ошибкам (совсем не свойственным статически-типизированным языкам):

      let l = `Monitor 24.0

let res = match l with
          | `Laptop _    -> "We have a laptop"
          | `Cellphone _ -> "We have a cellphone"
          | `Monitol _   -> "We have a monitor"
          | _ -> "We have an unknown product";;

Как видите, при написании конструкции match я допустил опечатку в слове Monitor, и компилятор никак не сможет мне тут помочь. Код будет скомпилирован успешно и приведет к ошибочному поведению в ран-тайме.

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

Есть такое мнение, что язык с динамической типизацией – это на самом деле язык со статической типизацией, в котором есть всего лишь один тип. Надо сказать, что данная позиция не лишена основания. И что мы имеем с полиморфными вариантами? Фактически и получается, что везде, где мы их используем, мы работаем с одним и тем же типом, что сводит все преимущества статической типизации на нет. Получается весьма резкий переход от алгебраических типов, которые в известном смысле куда более статически типизированы, чем классы в ООП, к «без пяти минут динамике» под видом полиморфных вариантов. При этом стоит заметить, что OCaml, язык, в котором дебютировала концепция полиморфных вариантов, – это очень строгий, если можно так выразиться, статически-типизированный язык, в котором даже используются разные арифметические операторы для целых и вещественных чисел. Очевидно, что, хотя полиморфные варианты и решают вышеозначенную проблему «закрытости» алгебраических типов, в такой язык как OCaml они вписываются не очень-то хорошо.

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

Вместо заключения

Признаюсь – вряд ли у меня получилось ответить на все вопросы, да и вряд ли все согласятся с моими ответами. Но все же, надеюсь, что для кого-то знакомство с функциональным программированием в стиле «вопросов и ответов» показалось по крайней мере не менее полезным, чем обсуждение причин по которым функциональное программирование должно завоевать мир.

Что прочитать дальше? Какой должна быть «первая глава»? Да и нужна ли она вообще?

Это решать, бесспорно, вам. Впрочем, могу сказать, что если вы попробуете изучить какой-нибудь небольшой функциональный язык (например, Ela), то это пойдет вам только на пользу. Вопреки расхожей молве, «погружение» в функциональное программирование вряд ли «перевернет» сознание или полностью изменит «образ мыслей» опытного программиста (а если и изменит – то совсем чуть-чуть). И уж точно ничто не помешает вам и дальше продолжать успешно работать на C#, C++ или любом другом языке, на котором вы программируете. Если, конечно, – по какой-нибудь неожиданной причине – вы сами не захотите что-нибудь изменить.

Список литературы

  1. Ela, dynamic functional language (http://elalang.net/ElaBook.aspx)


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