Функциональное программирование для всех

Автор: Вячеслав Ахмечет
Перевод: Линкер Николай
Источник: Programming For The Rest of Us
Материал предоставил: RSDN Magazine #2-2006
Опубликовано: 16.09.2006
Версия текста: 1.0
Введение
Прогулка по парку
Немного истории
Функциональное программирование
Преимущества ФП
Юнит-тестирование (Unit testing)
Отладка
Параллелизм
Горячая замена кода
Машинные доказательства и оптимизация
Функции высшего порядка
Карринг
Ленивые вычисления
Оптимизация
Абстрагирование от управляющих структур
Бесконечные структуры данных
Недостатки
Продолжения
Сопоставление с образцом (Pattern Matching)
Замыкания
Что дальше?
Комментарии?

Введение

Мы, программисты, редкие мастера тянуть время. Мы приходим на работу, наливаем себе кружечку кофе, проверяем почту, проверяем RSS, пробегаемся по новостям. Знакомимся с последними статьями на технических сайтах, немного воюем в политических, философских и других священных войнах в соответствующих ветках на форумах, посвящённых программированию. Потом возвращаемся к началу и повторяем опять, чтобы окончательно удостовериться, что ничего важного не пропущено. Идём на обед. Возвращаемся, в течение некоторого времени даже сверлим глазами любимый IDE. Проверяем почту. Наливаем ещё кофейку. Не успеваем оглянуться, как уже вечер и пора домой.

Есть, правда, одна вещь: время от времени всплывают статейки, требующие определённого напряжения. Если вы заглядываете в правильные места, вы будете натыкаться на, по меньшей мере, одну такую статью каждые 2-3 дня. Такие статьи тяжелы, чтобы быть прочитаны «наскоком», поэтому они имеют обыкновение накапливаться. Вы и глазом не успеваете моргнуть, а у вас уже есть внушительный список ссылок и каталог, заполненный PDF-ами «под завязку». Вы думаете, что здорово было бы отправиться на годик пожить в маленькой хижине где-нибудь в лесу, где в радиусе нескольких километров не было бы ни души! И чтобы кто-нибудь каждое утро, пока вы прогуливаетесь вдоль речушки неподалёку, приносил пищу и уносил мусор.

Я не знаю, какова тематика материала в вашем списке, но у меня подавляющее количество статей посвящено функциональному программированию. Сквозь эти статьи сложнее всего «продраться». Они написаны на сухом академическом языке, и даже «матёрые волки» с 10-летним опытом не всегда понимают, о каком именно аспекте функционального программирования (называемом для краткости просто ФП) эти статьи рассказывают. Если вы спросите какого-нибудь менеджера из CITI Group или из Deutsche Bank, почему они использовали JMS вместо Erlang, он ответит, что, мол, они не могут позволить себе использовать академические языки для «конкретных» индустриальных приложений.

ПРИМЕЧАНИЕ

Когда я искал работу в конце 2005-го года, я часто задавал этот вопрос. Довольно забавно было наблюдать пустые ничего не понимающие взгляды в ответ. Я думаю, что уж за 300 000 долларов в год эти люди могли бы иметь совесть изучить или хотя бы получить подробное представление о большей части доступных инструментов.

Только проблема-то в том, что некоторые из самых сложных систем с самыми жёсткими требованиями оказываются написанными с использованием элементов ФП. Что-то не срастается в этом мире...

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

Так что такое ФП? Как Оно работает? Оно вообще съедобно? Если Оно настолько полезно, как утверждают его адепты, почему Оно не используется в индустрии чаще? Почему только люди с PhD стремятся использовать Его? Даже ещё важнее, почему Его так чертовски трудно изучить? Какой толк от «замыканий», «продолжений», «карринга», «ленивых вычислений» и «отсутствия побочных эффектов»? Как Его можно использовать вне стен университетов? Почему Это кажется таким чужеродным всему тому, что дорого нашим «императивным» сердцам? Мы очень скоро приоткроем завесу. Но давайте пока начнём с выяснения причин огромного разрыва между реальным миром и тем, что мы наблюдаем в академических статьях. Найти ответ так же просто, как прогуляться по парку.

Прогулка по парку

Запускаем машину времени. Мы прогуливаемся по парку более двух тысяч лет назад, в прекрасный солнечный день давно забытой весны 380 года до нашей эры. Снаружи городских стен древнего города Афины, под приятно прохладной тенью оливковых деревев мы наблюдаем, как философ Платон неспешно двигается по направлению к Академии с симпатичным любопытным мальчишкой. Погода чудесная, время как раз после вкусного и сытного обеда – самое время для философии.

«Посмотри на этих двух учеников», – сказал Платон, осторожно подбирая слова так, чтобы сделать вопрос просвещающим: «Как ты думаешь, какой из них выше?». Мальчишка посмотрел в сторону небольшого озера, около которого стояли два ученика: «Они примерно одинакового роста». «Что ты имеешь ввиду под ‘примерно одинаковым’» – спросил Платон. «Ну-у, если смотреть отсюда, то они одинакового роста, но если бы я подошёл поближе, то, может, заметил бы разницу».

Платон улыбнулся. Он вёл мальчика в правильном направлении. «Так, ты хочешь сказать, что нет ничего абсолютно равного в нашем мире?» После некоторого раздумья мальчик ответил: «Я думаю, что да. Всё имеет какое-нибудь даже маленькое отличие, даже если мы не можем это увидеть». Ответ попал в точку! «В таком случае, поскольку нет ничего абсолютно равного в этом мире, как ты понимаешь идею ‘идеального’ равенства?» Мальчик выглядел озадаченным. «Я не знаю» – ответил он наконец.

Таким образом, была сделана первая попытка понять природу математики. Платон предложил идею, что всё в нашем мире всего лишь приближение к идеальному. Он также осознал, что мы понимаем идею идеального даже ни разу в жизни не столкнувшись с ним. И Платон пришёл к выводу, что идеальные математические формы должны жить где-то в другом мире, и что мы каким-то образом узнаём о них, будто у нас есть связь с той «параллельной» Вселенной. Совершенно ясно, что не существует идеальной окружности, которую мы можем наблюдать. Однако мы понимаем, что идеальная окружность существует, и мы можем описать её с помощью уравнений. Тогда что такое математика? Почему Вселенная описывается математическими законами? Можем ли мы все явления в нашей Вселенной описать с помощью математики?

ПРИМЕЧАНИЕ

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

Философия математики – это очень сложный предмет. И, как большинство философских дисциплин, она гораздо больше приспособилась замечательно ставить вопросы, чем давать удовлетворительные ответы. Большая часть соглашений вращается вокруг того факта, что математика в действительности это головоломка: мы создаём базовый непротиворечивый набор принципов и правил, как мы собираемся оперировать этими принципами. Мы можем потом сочетать эти правила, чтобы получить ещё более сложные правила. Математики называют этот метод «формальной системой» или «исчислением». Мы могли бы написать формальную систему для Тетриса, если бы хотели. Фактически, работающая реализация Тетриса как раз и является формальной системой, просто использует необычное представление.

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

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

Немного истории

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

ПРИМЕЧАНИЕ

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

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

Одним из людей, живших в таких комфортных условиях, был молодой математик Алонзо Чёрч (Alonzo Church). Алонзо уже получил степень бакалавра, и его пригласили в аспирантуру. Для Алонзо эта архитектура слишком причудлива, причудливее чем необходимо. Он редко показывался в обществе, чтобы подискутировать о математике за чашкой чая и не особо любил прогулки в лесу. Алонзо был скорее отшельником: он был гораздо продуктивнее, если работал в одиночестве. Тем не менее, Алонзо регулярно общался с другими учёными в Принстоне. Среди тех учёных были Алан Тьюринг (Alan Turing), Джон фон Нейман (John von Neumann) и Курт Гёдель (Kurt Godel).

Эти четыре человека концентрировали свои исследования на формальных системах. Они не слишком старались сохранить связь с физическим миром, гораздо больше они интересовались абстрактными математическими головоломками. Все их головоломки имели нечто общее: они работали над поиском ответов на вопросы о вычислениях. Если бы у нас была машина бесконечной вычислительной мощности, какие задачи мы бы смогли решать? Могли бы мы их решать автоматически? Могли ли некоторые задачи остаться неразрешимыми и почему? Могли ли разные машины с разной архитектурой быть равными по мощности?

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

ПРИМЕЧАНИЕ

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

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

ПРИМЕЧАНИЕ

(Примечание переводчика)

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

“f(x) = x” := λ x. x тождественная функция

“f(x) = c” := λ x. c постоянная функция со значением c для любого аргумента

“f(x) = x+2” := λ x. x+2 функция «прибавить два»

“f(x, y) = x-y” := λ x y. x-y функция вычитания

Как обычно, имя связанных формальных параметров особого значения не имеет. Определяются правила преобразования лямбда-выражений (правила обозначаются греческими буквами α, β и η). Эти правила формализуют переход между различными записями одной и той же функции (термин «одна и та же функция» в интуитивном понимании). Например:

(λ x y. x-y) 5 2 - (λ y. 5-y) 2 - 5-2

С помощью лямбда-выражений можно определять разные вещи, например булевы константы, логические функции, натуральные числа (называемые числа Чёрча):

0 := λ f x. x

1 := λ f x. (f x)

2 := λ f x. (f (f x))

3 := λ f x. (f (f (f x)))

Можно определить прибавление единицы, сложение,

SUCC := λ n f x. f(n f x)

PLUS := λ (m n f x). (m f (n f x))

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

Дальнейшие подробности можно узнать в Википедии или из классических учебников по теории вычислений.

Это вроде бы точка, где история должна была бы закончится, и мне следовало бы начать сворачивать свои путешествия во времени, и вам, дорогой читатель, переходить к следующей странице... Если бы не Вторая Мировая Война. Весь мир был охвачен огнём. Армия США использовала артиллерию гораздо чаще, чем когда бы то ни было до этого. Попытки увеличить точность попадания привели к тому, что большая группа математиков непрерывно решала дифференциальные уравнения, требуемые для баллистических расчётов. Стало очевидно, что задача слишком трудоёмка, чтобы решаться вручную и это послужило стимулом к разработке различного оборудования для преодоления этой проблемы. Первая машина, предназначенная для решения баллистических уравнений, называлась Mark I и была произведена IBM. Машина весила пять тонн, состояла из 750 000 элементов и могла делать три операции в секунду.

Гонка, разумеется, далеко ещё не заканчивалась (как мы видим, она не закончена по сей день). В 1949 году был представлен EDVAC (Electronic Discrete Variable Automatic Computer), имевший оглушительный успех. Он был первым примером архитектуры фон Неймана и первым реальным воплощением в жизнь машины Тьюринга. На некоторое время исследования Алонзо Чёрча были забыты.

В 1950-м году профессор MIT Джон Маккарти (John McCarthy), тоже выпускник Принстона, заинтересовался работами Чёрча. И в 1958 году он представил Лисп (LISP, List Processing Language). Лисп был реализацией лямбда-исчисления для архитектуры фон Неймана. Не секрет, что множество учёных и программистов отмечают большую выразительную мощь Лиспа. В 1973 году группа программистов из MIT-овской Лаборатории Искусственного Интеллекта разработали процессор, который они назвали «Лисп-машина» – настоящее «железное» воплощение лямбда-исчисления Чёрча!

Функциональное программирование

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

Лямбда-исчисление было создано, чтобы исследовать задачи, связанные с вычислениями. Таким образом, функциональное программирование, прежде всего, имеет дело с вычислениями, и, что самое удивительное, использует функции для выполнения вычислений. Функция – это базовый «кирпич» в ФП. Функции используются почти везде, даже в простейших вычислениях. Даже переменные и те заменены функциями. В функциональном программировании переменные – это просто псевдонимы для выражений (так что нам необязательно записывать всё в одну строчку). Они (переменные) не могут быть изменены. Все переменные могут принимать значения только один раз. В терминах Явы это означает, что каждая, даже самая малозначащая переменная определена как константа (final; или const, если мы вспомним про C++). В ФП нет неконстантных (обычных) переменных.

final int i = 5;
final int j = i + 3;

Поскольку каждая переменная в ФП является константой, то можно сделать два любопытных наблюдения. Первое: незачем писать слово final и, второе: бессмысленно называть переменные, гхм… переменными. Мы просто внесем в нашу функциональную Яву следующие модификации: каждая объявленная переменная будет константой по умолчанию, и мы будем называть переменные символами.

К этому моменту вас, уважаемый читатель, наверняка замучило любопытство, как можно написать что-нибудь значительное на получившемся языке? Ведь, поскольку каждый символ не может быть изменён, мы не можем менять состояние у чего бы то ни было! Однако последний вывод не совсем верен. Когда Алонзо работал над лямбда-исчислением, ему не требовалось поддерживать состояние на протяжении некоторого времени, чтобы потом позже изменить его. Чёрча интересовало, как можно совершать операции над данными. Из предыдущего параграфа мы помним, что лямбда-исчисление эквивалентно машине Тьюринга. Следовательно, в принципе можно сделать всё то же самое, что и на обычном императивном языке. Но тогда как? Как мы можем достигнуть тех же самых результатов?

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

ПРИМЕЧАНИЕ

Любопытно, что строки в Яве в любом случае неизменны (immutable). Довольно интересно обсудить причины такого поведения, но это нас отвлечёт от нашей цели.

String reverse(String arg)
{
  if(arg.length == 0)
    return arg;
  else
    return reverse(arg.substring(1, arg.length)) + arg.substring(0, 1);
}

Эта функция медленнее, чем нужно, потому что рекурсивна. Также ей нужно слишком много памяти, потому что она много раз выделяет объекты в куче.

ПРИМЕЧАНИЕ

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

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

Преимущества ФП

Вы, вероятно, думаете, что я вряд ли смогу оправдать монстрообразность функции, приведенной выше. Когда я изучал функциональное программирование, я тоже так думал. Скажу прямо, я ошибался. Есть ряд очень привлекательных аргументов в пользу использования такого стиля. Некоторые из них весьма субъективны. Например, есть мнение, что функциональные программы легче читать и понимать. Я не буду принимать во внимание такие аргументы, потому что каждый знает, что лёгкость чтения сильно зависит от того, кто читает. К счастью для меня, существуют и объективные аргументы.

Юнит-тестирование (Unit testing)

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

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

Отладка

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

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

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

ПРИМЕЧАНИЕ

(Примечание переводчика)

В отладчике для функционального языка возможно реализовать команду «шаг назад». И это не фантастика – например, такая возможность есть в отладчиках ocamldebug для языка OCaml, Haskell tracer (Hat) для языка Haskell, lispdebug для нескольких реализаций языка Lisp. Для императивных языков такое тоже в принципе возможно, но гораздо тяжелее и дороже.

Параллелизм

Функциональная программа уже готова для исполнения в параллельном режиме без дополнительных модификаций. Вам не нужно беспокоиться о взаимоблокировках (deadlocks) и борьбе за ресурсы (race conditions), потому что вам просто не потребуется использовать примитивы синхронизации (locks). Никакой блок данных не модифицируется дважды одним и тем же потоком, не говоря уже о двух и более потоках. Это означает, что вы легко можете добавлять потоки, не утруждая себя думами о стандартных проблемах, которые неизбежно сопровождают многопоточные приложения!

Конечно, у вас закралось сомнение – раз так всё здорово, почему никто не использует ФП для создания приложений использующих большое число потоков? Как оказалось, есть люди, использующие ФП для таких приложений и получающие большую выгоду от этого. Компания Ericsson разработала функциональный язык Эрланг (Erlang) для построения высоконадёжных и масштабируемых систем телекоммуникации. Ряд других компаний увидели преимущества в использовании Эрланга и начали использовать его. Речь идёт о системах телекоммуникации и контроля, которые намного более масштабируемые и надёжные, чем типичные системы, рождённые из недр Уолл-стрит (Wall Street). Хотя … на самом деле системы на Эрланге вовсе не масштабируемые и совсем не надёжные. Это системы на Яве такие. Системы на Эрланге просто непробиваемы как скалы.

ПРИМЕЧАНИЕ

Очень многие авторы, пропагандирующие ФЯ, перегибают палку и выдают желаемое за действительное. Например, поговорив об автоматическом распараллеливании кода, как в данном случае, заканчивают триумфальной тирадой об Эрланг, который никакого автоматического распараллеливания как раз и не производит. Вместо этого он предлагает идеологию, похожую на идеологию активных объектов, описанную в одной из статей этого номера. В Эрланг вводится понятие процесса, с которым можно взаимодействовать посредством асинхронных сообщений. Для упрощения этого в язык встроен небольшой, но мощный набор примитивов. Более того, до недавнего времени Эрланг вообще не осуществлял автоматическое выполнение разных процессов на разных процессорах системы (физических потоках ОС). Вместо этого Эрланг выполнял все свои процессы, размещающиеся в одном физическом процессе ОС, в рамках одного физического потока. Только недавно появилось сообщение о поддержке автоматического выполнения Эрланг-процессов в разных потоках ОС на SMP-системах. Фактически, имеется некоторый паттерн, встроенный в язык и не имеющий отношения к распараллеливанию вычислений, о котором так часто любят говорить приверженцы функциональных языков. Сколько-нибудь распространенных систем, реально автоматически распараллеливающих вычисления, пока нет. Возможно, некоторые исследовательские проекты и делают это, но они пока не покинули стен институтов.

К тому же возможности Эрланг проецируются на все функциональные языки как таковые. Фактически производится подмена предмета – если в Эрланг есть поддержка параллелизма, и Эрланг является функциональным языком, значит, это преимущество функциональных языков. Создается впечатление, что в не функциональных языках подобное невозможно или ужасно сложно. Однако это не так. К примеру, подход, очень похожий на используемый в Эрланг, используется в Sing# (модифицированном варианте C#), используемом при разработке экспериментальной ОС Singularity, о которой мы писали в одном из прошлых номеров.

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

Меж тем мир ФП – это необычайно интересный мир с множеством находок, практически неизвестных основой массе программистов. Что интересно, многое из мира ФП никак не связано с догматами ФП, а просто родилось и развивалось в этом мире, и только поэтому считается его частью. Интересно, что большинство ФЯ не являются таковыми на 100%. Они поддерживают все или большинство приемов ФП, но при этом позволяют программировать императивно. Есть только небольшое количество чисто функциональных языков, реально используемых ныне на практике. Пожалуй, самыми известными из них являются Haskell и Эрланг.

Большинство приемов ФП можно или использовать в императивных языках напрямую, или добавить в императивные языки в виде синтаксических конструкций. К сожалению, большинство ФЯ не только представляют функциональную парадигму, но и имеют семантику, очень непонятную программисту, привыкшему к С- или Паскале-подобному синтаксису. Сейчас появляются языки, пытающиеся ввести синтаксические конструкции, традиционные для ФП, в язык с традиционным С-подобным синтаксисом, например, Scala и Nemerle. Более того, даже изначально императивные языки программирования тем или иным путем впитывают некоторые концепции ФП. Так, в C++ функциональные веяния проникают через библиотеки (такие как STL и boost), а C# с каждой новой версией включает все больше и больше возможностей, традиционно относимых к функциональным.

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

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

String s1 = somewhatLongOperation1();
String s2 = somewhatLongOperation2();
String s3 = concatenate(s1, s2);

В функциональном языке компилятор может проанализировать код, вывести, что функции, которые создают строки s1 и s2, суть потенциально длительные операции, и запустить их параллельно. В общем случае это невозможно сделать в императивном языке, потому что каждая функция может модифицировать внешнее состояние, и функции, следующие ниже, могут зависеть от этого состояния. В функциональных языках автоматический анализ функций и поиск кандидатов для параллельного исполнения так же естественен, как и автоматический инлайнинг (inlining). В этом смысле программы в функциональном стиле сохраняют свою актуальность в будущем. Производители процессоров могут облегчить себе жизнь и больше не гнаться за скоростью (ну или не так сильно гнаться). Вместо этого они могут просто увеличивать количество ядер и начать массированную рекламную кампанию «многократное увеличение скорости» за счёт параллельности. Естественно, они случайно забудут о том, что мы за наши кровные получаем прирост только для распараллеливаемого кода (у PR-менеджеров подобные потери памяти случаются весьма часто, так что им не привыкать). Для императивных программ это будет не очень большая часть, но для функциональных программ - это 99%, так как функциональные программы замечательно подходят для распараллеливания.

Горячая замена кода

Во времена ранних версий Windows, чтобы установить обновление, было необходимо перезагружать компьютер. Много раз. Даже после установки новой версии медиаплеера. С появлением Windows XP ситуация существенно улучшилась, однако она всё ещё далека от идеала (сегодня на работе я запустил Windows Update, и сейчас надоедливая иконка в трее говорит мне, что прежде чем продолжить, я должен перезагрузить машину). Системы Unix имеют несколько лучшую модель поведения и уже довольно давно. Чтобы установить обновление, мне нужно только остановить соответствующие компоненты, необязательно всю ОС. Хотя это лучшее решение, но для большого числа серверных приложений это по-прежнему недопустимо. Телекоммуникационные системы должны быть доступны 100% времени, потому что если вдруг связь перестанет работать из-за установки обновлений, последствия могут быть самыми серьезными – от материальных потерь до человеческих жертв. Нет уважительных причин, по которым фирмы с Уолл-стрит должны останавливать свои системы, но они делают это потому, что используют несовершенные технологии.

Идеальная ситуация – это обновлять код без остановки какой-бы-то-ни-было части системы вообще. В императивном мире это практически невозможно. Рассмотрим, например, выгрузку класса в Яве во время исполнения и загрузку нового варианта. Если мы сделаем это, каждый объект этого класса станет непригодным для дальнейшего использования, потому что состояние, которое он хранит, будет потеряно. Мы бы могли прибегнуть к написанию хитрого контроля версий. Мы бы могли сохранить все запущенные экземпляры класса, разрушить их, создать экземпляры нового класса, попытаться загрузить сохранённые данные в новые экземпляры, а потом кусать ногти, надеясь, что данные правильно преобразуются и «лягут» в новые экземпляры. Каждый раз, когда мы должны заменить что-то, мы должны написать это преобразование (migration code) вручную. Наконец, при создании такого преобразования мы должны уделить особое внимание связям между объектами и не разрушить их. Теоретически, для «настоящих джедаев» нет ничего невозможного, но на практике если что и работает, то с существенными ограничениями.

ПРИМЕЧАНИЕ

(Примечание переводчика)

Отладчики в Яве – типичный пример, инкрементальная отладка это очень полезная возможность, позволяющая сэкономить массу времени. Тем не менее, даже самые лучшие отладчики (в Eclipse и Intellij IDEA) позволяют горячую замену кода только в случае, если изменения не затрагивают структуру, то есть можно менять только реализацию метода. Здесь срабатывает принципиальное ограничение HotSwap: если вы пытаетесь хотя бы добавить методы или поля во время работы программы, Virtual Memory System пользовательской JVM просто не даст сделать такую замену.

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

Машинные доказательства и оптимизация

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

ПРИМЕЧАНИЕ

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

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

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

Функции высшего порядка

Я помню себя, когда я слушал о приведенных выше преимуществах и думал «всё это конечно здорово, но эти якобы преимущества – полная ерунда, если мне придётся программировать на таком уродливом языке, где всё является константой». Это неправильное представление. Превращение всех переменных в константы является уродством в контексте Ява-подобных императивных языков, но не в контексте функциональных языков. Функциональные языки предлагают другие, лучшие способы абстракции, после которых вам в большинстве случаев не захочется модифицировать переменные. Один из таких способов – это возможность работать с функциями высшего порядка (higher order functions).

Функции в таких языках серьезно отличаются от функций в Яве или Си. Практически это надмножество – функции в ФЯ могут всё то же, что и в Яве, и даже ещё больше. Мы можем создать функцию аналогично тому, как мы делаем это в Си:

int add(int i, int j)
{
  return i + j;
}

Это означает не совсем то, что такой же код на Си. Давайте немного расширим наш компилятор Явы, чтобы он позволял такую нотацию. Когда мы пишем что-то подобное, наш компилятор будет преобразовывать это в следующий код на Яве (не забываем, что всё является константой):

class add_function_t
{
  int add(int i, int j)
  {
    return i + j;
  }
}

add_function_t add = new add_function_t();

Символ add на самом деле не функция. Это экземпляр небольшого класса с одной функцией в качестве члена класса. Теперь мы можем передавать add куда угодно как аргумент для других функций. Мы можем присвоить его другому символу. Мы можем создавать экземпляры add_function_t во время исполнения, и они будут собираться сборщиком мусора, когда они будут не нужны. Это превращает функции в полноправные объекты, такие же, как целые или строки. Функции, которые оперируют над другими функциями (принимают их в качестве аргументов) называются функциями высшего порядка. Пусть вас не пугает этот термин, здесь полный аналог того, как объекты в Яве оперируют другими объектами (мы можем передавать объекты в методы других классов). Мы можем их назвать «классами высшего порядка», но это никому не нужно, поскольку вокруг Явы нет такого сильного академического сообщества.

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

Предположим, что у нас есть фрагмент кода на Яве, который принимает сообщение, преобразует его некоторым способом, и передаёт сообщение на другой сервер.

class MessageHandler
{
  void handleMessage(Message msg)
  {
    // ...
    msg.setClientCode("ABCD_123");
    // ...
    sendMessage(msg);
  }
  // ...
}

Теперь представьте, что наша система поменялась, и мы теперь рассылаем сообщения на два сервера вместо одного. Всё обрабатывается в точности как прежде, за исключением клиентского кода – второй сервер ожидает сообщения в другом формате. Как мы справимся с этой ситуацией? Мы могли бы проверять, куда сообщение должно быть направлено, и форматировать клиентский код соответственно, примерно так:

class MessageHandler
{
  void handleMessage(Message msg)
  {
    // ...
    if(msg.getDestination().equals("server1")
      msg.setClientCode("ABCD_123");
    else
      msg.setClientCode("123_ABC");
    // ...
    sendMessage(msg);
  }
  // ...
}

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

abstract class MessageHandler
{
  void handleMessage(Message msg)
  {
    // ...
    msg.setClientCode(getClientCode());
    // ...
    sendMessage(msg);
  }

  abstract String getClientCode();
  // ...
}

class MessageHandlerOne extends MessageHandler
{
  String getClientCode()
  {
    return "ABCD_123";
  }
}

class MessageHandlerTwo extends MessageHandler
{
  String getClientCode()
  {
    return "123_ABCD";
  }
}

Мы теперь можем создавать объект подходящего класса для каждого сервера. Добавление серверов теперь гораздо легче сопровождать. Но столько кода для такой простейшей модификации – это перебор. Мы вынуждены создать два новых типа просто для поддержки различного клиентского кода! Теперь давайте сделаем то же самое в нашем языке, который поддерживает функции высшего порядка:

class MessageHandler
{
  void handleMessage(Message msg, Function getClientCode)
  {
    // ...
    Message msg1 = msg.setClientCode(getClientCode());
    // ...
    
    sendMessage(msg1);
  }
  
  // ...
}

String getClientCodeOne()
{
  return "ABCD_123";
}

String getClientCodeTwo()
{
  return "123_ABCD";
}

MessageHandler handler = new MessageHandler();
handler.handleMessage(someMsg, getClientCodeOne);

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

Карринг

Большинство людей, с которыми я встречался, читали книгу «Приёмы объектно-ориентированного проектирования. Паттерны проектирования» Банды Четырёх. Любой уважающий себя программист скажет, что эта книга не привязана к какому либо языку, и паттерны применимы к созданию программного обеспечения вообще, то есть независимо от языка, который вы используете. Это довольно претенциозное заявление. И, к сожалению, оно весьма далеко от истины.

Функциональные языки очень выразительны. В функциональном языке не нужны паттерны проектирования, потому что язык – высокоуровневый. Высокоуровневый настолько, насколько вообще можно ожидать от языка. У вас появляется возможность программировать в концепциях, покрывающих все паттерны. Один из таких покрываемых паттернов – это паттерн Адаптер (опять же, насколько он отличается от Фасада? Выглядит так, как будто кому-то нужно было добрать страниц, дабы выполнить условия контракта). Этот паттерн почти устраняется, как только язык начинает поддерживать технику, называемую каррингом (currying).

Паттерн Адаптер лучше всего показывает себя, когда применяется к основной единице абстракции в Яве – к классам. Следовательно, в функциональных языках этот паттерн применяется к функциям. Паттерн принимает интерфейс и преобразует его к другому, ожидаемому, интерфейсу. Пример применения:

int pow(int i, int j);

int square(int i)
{
  return pow(i, 2);
}

Приведённый код адаптирует интерфейс функции, которая возводит целое число в целую степень к интерфейсу функции, которая возводит целое число в квадрат. В академических кругах это тривиальное преобразование называется каррингом (в честь математика Хаскеля Карри (Haskell Curry), который провёл нетривиальные математические вычисления, чтобы формализовать эту технику). Поскольку в ФП функции (в противоположность классам) могут передаваться как аргументы, карринг используется, чтобы уменьшить количество аргументов (как в примере выше).

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

square = int pow(int i, 2);

Эта строка автоматически создаёт функцию square от одного аргумента. Вызов square будет просто вызывать функцию pow со вторым аргументом, равным двум. А сама строка будет компилироваться в следующий код на Яве:

class square_function_t
{
  int square(int i)
  {
    return pow(i, 2);
  }
}
square_function_t square = new square_function_t();

Как видите, мы просто создали обёртку для оригинальной функции. В ФЯ карринг таков и есть – быстрый и лёгкий способ создавать обёртки. Вы концентрируетесь на своей задаче, а компилятор пишет вспомогательный код за вас! Когда нужно использовать карринг? Это легко распознать – каждый раз, когда вам потребуется создать обёртку.

Ленивые вычисления

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

String s1 = somewhatLongOperation1();
String s2 = somewhatLongOperation2();
String s3 = concatenate(s1, s2);

В императивном языке порядок выполнения должен быть чётким. Поскольку каждая функция может зависеть от внешнего состояния или влиять на него, нужно их выполнять в строгом порядке: сначала somewhatLongOperation1, потом somewhatLongOperation2 и только потом concatenate. В функциональных языках появляется свобода для манёвра.

Как мы видели раньше, somewhatLongOperation1 и somewhatLongOperation2 могут выполняться параллельно, потому что нам гарантирована защита от того, что функция влияет на внешнее состояние или зависит от него. Но что если мы не хотим их выполнять параллельно, обязаны ли мы сохранять порядок выполнения? Нет, не обязаны. Нам достаточно выполнить эти операции, когда s1 и s2 будут явно использованы. Мы даже не должны вызывать их (операции) перед вызовом concatenate – мы можем отложить вычисления до момента, когда s1 или s2 понадобятся внутри concatenate. Если мы заменим вызов concatenate функцией, которая содержит условие и использует s1 или s2 в зависимости от условия, то мы вообще сможем не вычислять один из параметров! Хаскель (Haskell) – пример такого ленивого языка. В Хаскеле нет никаких гарантий, что что-нибудь будет вычислено в нужном порядке (или вычислено вообще), потому что Хаскель исполняет код только тогда, когда это требуется.

Оптимизация

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

Абстрагирование от управляющих структур

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

unless (stock.isEuropean())
{
  sendToSEC(stock);
}

Мы хотим, чтобы sendToSEC (SEC – комиссия по ценным бумагам и биржам в США – прим.пер.) исполнялась, кроме (unless) случая, когда акции (stock) принадлежат европейской компании. Как мы можем реализовать unless? Без ленивых вычислений нам нужна какая-нибудь макросистема, но в языках, подобных Хаскелю, здесь можно обойтись без макросов. Мы можем реализовать unless просто как функцию!

void unless(boolean condition, List code)
{
  if (!condition)
    code;
}

Заметим, что code никогда не выполняется, если условие истинно. Мы не сможем (в общем случае) воспроизвести данное поведение в энергичных (strict, то есть попросту не ленивых) языках, потому что аргументы должны вычисляться до входа внутрь unless.

Бесконечные структуры данных

Ленивые языки позволяют определять бесконечные структуры данных, что в энергичных языках сделать гораздо тяжелее. Например, рассмотрим список чисел Фибоначчи. Мы, очевидно, не можем вычислить бесконечный список за конечное время или сохранить его в памяти. В таких строгих языках, как Ява, мы просто определяем функцию, возвращающую определённое число из этой последовательности. В языке, подобном Хаскелю, мы можем переместиться на следующую ступеньку абстракции и просто определить бесконечный список чисел Фибоначчи. Поскольку язык ленивый, то на самом деле будут использованы только те элементы, которые реально использовались в программе. Этот приём позволяет избежать множества проблем и мелких частных случаев, и даёт возможность взглянуть на вещи с более общей точки зрения (например, можно использовать стандартные списочные функции для обработки бесконечных списков).

Недостатки

Конечно, бесплатный сыр бывает только в мышеловке. Ленивые вычисления обладают рядом недостатков. Главный из них – это, гхм… ленивость. Много задач в реальном мире требуют энергичной семантики исполнения. Например, взглянем на следующий код:

System.out.println("Please enter your name: ");
System.in.readLine();

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

К счастью, не всё потеряно. Математики засучили рукава и придумали целый ряд приёмов, обеспечивающих правильный порядок в ленивом функциональном окружении. Эти приёмы включают продолжения (continuations), монады (monads) и уникальную типизацию (uniqueness typing). В этой статье мы затронем только продолжения, а монады и уникальные типы оставим на следующий раз. Любопытно, что продолжения полезны во многих аспектах, не только в приведении исполнения к определённому порядку, хотя этого вопроса мы также коснёмся, разумеется.

Продолжения

Продолжения для программирования подобны коду Да Винчи для истории: удивительное открытие самой сокровенной загадки, известной человеку. Хотя… Может быть я немного перегнул палку с метафорой, но они определённо раздвигают горизонт познания подобно квадратному корню от отрицательного числа, открывшему путь в целый раздел математики.

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

int i = add(5, 10);
int j = square(i);

Функция add возвращает число 15, которое впоследствии должно быть присвоено i. Это то самое место, откуда add была изначально вызвана. После этого значение символа i используется при вызове square. Заметим, что ленивый язык не может переставить вызовы, потому что вторая строка зависит от успешного выполнения первой строки. Мы можем переписать этот фрагмент, используя «стиль передачи продолжений» (CPS, continuation passing style), когда функция add не возвращает результат в точку после вызова, а вместо этого возвращает результат в square.

int j = add(5, 10, square);

В этом случае add получает ещё один параметр – функцию, которой add должна передать результат после завершения. В этом случае square – это продолжение для add. В обоих случаях j станет равным 225.

Это первый приём, который позволяет заставить ленивый язык выполнить два выражения в нужном порядке. Рассмотрим следующий (очень знакомый) код для ввода-вывода:

System.out.println("Please enter your name: ");
System.in.readLine();

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

System.out.println("Please enter your name: ", System.in.readLine);

В этом случае println должен вызвать readLine со своим результатом и вернуть результат выполнения readLine. Это даёт нам уверенность, что две строчки выполнятся так, как требуется, и что readLine выполнится вообще (потому что всё вычисление ожидает последнего значения в качестве результата). Правда, есть проблема – в случае Явы println возвращает void. Но если мы расширим наш Ява-подобный язык так, что println будет возвращать некоторое абстрактное значение, принимающее readLine, то наша задача будет решена! Конечно, наивное завязывание функций в цепочку, подобной выше, быстро превратит программу в нечитаемый дремучий лес, однако мы же не настолько наивны. Мы можем добавить такой синтаксический сахар в наш язык, который позволит нам непосредственно записывать выражения в определённом порядке, а компилятор сам автоматически будет завязывать выражения в цепочки. После таких улучшений мы можем вычислять выражения в любом порядке, в котором нам захочется без потери остальных преимуществ ФЯ (включая возможность вывода свойств математически). Если возникли затруднения, вспомните, что функции – это просто экземпляры классов с одним методом. Попробуйте переписать 2 строчки выше так, как будто println и readLine – это объекты, и всё встанет на свои места.

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

// обычный вариант
int i = add(5, 10);
int j = square(i);
int k = sub(j, i); // k = 210

// промежуточный вариант 
int    i = add(5, 10);
// функция square теперь принимает целое i и возвращает пару (i^2, i)
(int, int) (j, i) = square(i);
int    k = sub(j, i); // k = 210

// СПП-вариант
int k = add(5, 10, square(sub)); // k = 210

Эту конверсию легко провести автоматически (фактически, многие компиляторы делают это).

Как только мы сконвертировали нашу программу к СПП, стало ясно, что каждая инструкция имеет некоторое продолжение, то есть функцию, куда потом будет передаваться результат. В обычной программе это будет точкой возврата, то есть точкой сразу после вызова. Давайте возьмем некоторую инструкцию из кода выше, например add(5, 10). В программе, написанной в СПП, очевидно, что продолжением для add(5, 10) является функция, которая будет вызвана, как только add отработает. Но что это будет в программе без СПП? В принципе, мы бы могли преобразовать программу так, чтобы она была написана в СПП, и тогда бы стало ясно, что будет являться продолжениями. Но обязательно ли проводить такую конверсию, чтобы это узнать?

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

Таким образом, программы, написанные в СПП, не имеют стека, но имеют дополнительный аргумент-функцию, которая потом будет вызвана (собственно, аргумент-продолжение). Сравните: программы, не написанные в СПП, не имеют такого дополнительного аргумента, но зато имеют стек. Что содержит стек? Стек содержит аргументы и адрес, куда функция должна вернуться. На вас уже снизошло озарение? Стек просто содержит информацию о продолжении! Адрес возврата – это, в принципе, та же самая вещь, что и тот дополнительный аргумент (функция) в СПП-программах. Если бы вы захотели найти продолжение для add(5, 10), вам достаточно было бы заглянуть в стек в точке вызова!

Вот оно как, оказывается. Продолжение и адрес возврата в стеке суть одна и та же вещь, с той лишь разницей, что продолжение передаётся явно. Явная передача позволяет задать произвольную точку, а не только точку возврата. Мы помним, что продолжение – это функция, функция в нашем языке компилируется в экземпляр класса. Мы выяснили, что адрес возврата в стеке и продолжение – это действительно одно и то же, следовательно, наше продолжение – это указатель на объект. Это означает, что в любой момент времени в программе вы можете спросить текущее продолжение (которое есть просто информация в стеке).

Хорошо, теперь мы знаем, что у нас является текущим продолжением. Что это даёт? А вот что. Когда мы получаем текущее продолжение и сохраняем его где-нибудь, мы таким образом сохраняем состояние нашей программы – как бы замораживаем её во времени (или делаем фотографию – как вам больше нравится). Это подобно переводу операционной системы в «спячку» (hibernation). Объект продолжения содержит информацию, необходимую для перезапуска программы с точки, в которой этот объект был захвачен. Операционная система делает такие «заморозки» с вашей программой каждый раз, когда переключает управление между процессами или потоками. Единственная разница в том, что она полностью контролирует эти вещи, и повлиять на них можно разве что системными хаками. Если мы запрашиваем объект продолжения сами (в Scheme это можно сделать с помощью функции call-with-current-continuation), то получаем объект, содержащий текущее продолжение – стек (или в случае СПП – следующую функцию для вызова). Мы можем сохранить этот объект в переменной (или сбросить на диск). Когда мы решим перезапустить программу, мы этот объект продолжения «трансформируем» в состояние программы на момент, когда мы захватывали продолжение. Опять же, в случае операционной системы это процесс, когда управление возвращается приостановленному потоку, или когда происходит пробуждение системы от «спячки». Разница в том, что мы можем делать это снова и снова, в отличие от ОС – как только ОС вернулась к активному состоянию, информация о «замороженном» состоянии разрушается. Если бы эта информация не разрушалась, мы бы могли пробуждать систему с одной и той же точки снова и снова, как бы совершая скачки во времени в прошлое. С продолжениями у нас есть такая машина времени!

В каких ситуациях полезны продолжения? Обычно, когда вы пытаетесь симулировать состояние в приложении, которое внутренне не обладает состоянием, продолжения облегчат вам жизнь. Самый известный пример таких приложений – это Web-приложения. Microsoft со своим детищем ASP.NET идёт на всё, чтобы попытаться симулировать состояние. Всё ради нас – чтобы мы могли писать наши приложения, испытывая наименьшие трудности. Однако, если бы C# поддерживал продолжения, половина сложности фреймворка ASP.NET исчезла бы без следа – мы бы просто сохраняли продолжение и перезапускали его, когда пользователь снова делает Web-запрос. Для разработчика Web-приложения не было бы никаких прерываний – программа просто начала бы выполняться со следующей строки! Продолжения – это невероятно полезная абстракция для некоторых задач. А в свете последних тенденций, когда многочисленные, традиционно «толстые» клиенты, начинают двигаться в сторону Web-а, продолжения будут играть всё более значительную роль.

Сопоставление с образцом (Pattern Matching)

Сопоставление с образцом (pattern matching) не является новейшей или передовой возможностью. Фактически оно даже мало соотносится с функциональным программированием. Единственная причина, по которой оно часто приписывается ФП – это потому, что функциональные языки обладают такой возможностью уже очень давно, в то время как современные императивные языки всё ещё не имеют её (я имею в виду, прежде всего, C++, Яву и C#).

Давайте рассмотрим пример. Это функция Фибоначчи на традиционной Яве:

int fib(int n)
{
  if (n == 0) return 1;
  if (n == 1) return 1;
  return fib(n - 2) + fib(n - 1);
}

А это пример той же функции в нашем создаваемом Ява-подобном языке, который (вуаля!) уже поддерживает сопоставление с образцом:

int fib(0)   { return 1; }
int fib(1)   { return 1; }
int fib(int n) { return fib(n - 2) + fib(n - 1); }

В чём различие? Компилятор реализует ветвление за нас.

И что, это фантастически круто? Ну, не так чтобы фантастически, но определённые преимущества есть. Кое-кто заметил, что много функций содержат очень сложные операторы switch (это особенно справедливо для функциональных программ) и решил, что неплохо было бы абстрагировать эти нагромождения из условий. Вы разбиваете определение вашей функции на несколько определений и задаёте образцы для некоторых аргументов (что-то наподобие перегрузки). Когда функция вызывается, компилятор сравнивает аргументы с определениями (во время исполнения) и выбирает подходящее. Обычно выбирается самое специфичное определение. Например, при вызове fib(1) в принципе может быть вызвана int fib(int n) с n равным 1, но она не будет вызвана, поскольку определение int fib(1) более специфично.

ПРИМЕЧАНИЕ

Примечание переводчика

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

Сопоставление с образцом обычно сложнее, чем это показывают наши примеры. Скажем, продвинутая система позволяет делать следующее:

int f(int n < 10) { ... }
int f(int n) { ... }

Когда полезна такая возможность? В это трудно поверить, но таких случаев очень много! Каждый раз, когда имеется сложная структура вложенных if-ов, сопоставление с образцом может сделать работу лучше и с меньшим количеством кода. Первая функция, которая приходит на ум – это стандартная WndProc, которую должно реализовать любое Win32-приложение (хотя она часто скрыта каким-нибудь абстрактным слоем). Обычно система сопоставления с образцом может проверять как простые значения, так и целые коллекции. Если вы передаёте массив в функцию, вы можете, например, выбирать все массивы, у которых первый элемент равен 1, а третий элемент больше 3.

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

ПРИМЕЧАНИЕ

Примечание переводчика

Например, при наличии сопоставления с образцом почти не нужны такие свойства языка, как множественная диспетчеризация (multiple dispatch) и паттерн проектирования Посетитель (Visitor).

Как только вы привыкните к этой штуковине, вы будете удивляться, как раньше без неё жили!

Замыкания

До этого момента мы обсуждали возможности в контексте чистых функциональных языков – реализаций чистого лямбда-исчисления, которые не содержат средств, конфликтующих с формализмом Чёрча. Однако множество возможностей функциональных языков остаются полезными и за пределами формальной системы лямбда-исчисления. Несмотря на то, что чистые реализации очень полезны, поскольку допускают интерпретацию программ как математических выражений, такая интерпретация может быть, а может и не быть практически целесообразной (во всяком случае, на сегодняшний день нет компилятора, который бы выжимал всё возможное из такого подхода). Многие языки следуют подходу гармоничного включения функциональных элементов без строгого следования функциональной доктрине. Много языков (например, Common Lisp) не требуют, чтобы переменные были константами – вы можете модифицировать значения на месте. Они также не требуют, чтобы функции зависели только от своих параметров – функции могут получать доступ к данным за пределами своего определения. Но такие языки включают и функциональные элементы – например, функции высшего порядка. Хотя надо сказать, что передача функций в «нечистых» языках несколько отличается от того же в условиях соблюдения ограничений лямбда-исчисления. В «нечистых» языках почти всегда требуется поддержка такой вещи, как лексическое замыкание (lexical closure). Давайте взглянем на небольшой фрагмент кода. Теперь будем считать, что переменные не обязаны быть константными, и функции могут обращаться к внешним переменным:

Function makePowerFn(int power)
{
   int powerFn(int base)
   {
     return pow(base, power);
   }

   return powerFn;
}

Function square = makePowerFn(2);
square(3); // возвращает 9

Функция makePowerFn возвращает функцию, которая принимает один аргумент и возводит его в определённую степень. Что произойдёт, когда мы попытаемся вычислить square(3)? Переменная power уже вышла из области видимости powerFn, потому что makePowerFn возвратила свой стек, и он уже разрушен. В таком случае, как работает square? Язык должен где-то сохранить значение переменной power, чтобы square смогла работать. Что, если мы создаём другую функцию, cube, которая возводит что-нибудь в третью степень? Получается, что среда исполнения должна сохранить две копии power, по одной копии для каждой из функций, созданных с помощью makePowerFn. Функция с эффектом сохранения этих значений и называется замыканием (хотя так называют и сам эффект). Замыкания не только сохраняют аргументы в главной функции. Например, замыкание может работать так:

Function makeIncrementer()
{
   int n = 0;

   int increment()
   {
     return ++n;
   }
}

Function inc1 = makeIncrementer();
Function inc2 = makeIncrementer();

inc1(); // возвращает 1;
inc1(); // возвращает 2;
inc1(); // возвращает 3;
inc2(); // возвращает 1;
inc2(); // возвращает 2;
inc2(); // возвращает 3;

Среда исполнения выделяет место для хранения переменной n, так что «увеличители» могут получить к ней доступ. Более того, она (среда) выделяет разные блоки памяти для каждого «увеличителя», несмотря на то, что они, казалось бы, исчезли в момент, когда makeIncrementer вернула управление. Во что этот код должен компилироваться? Как работают замыкания внутри? К счастью, мы можем спуститься на ступеньку ниже.

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

ПРИМЕЧАНИЕ

На самом деле это ненамного медленнее, чем сохранение в стеке, потому что с введением подходящего сборщика мусора сложность выделения памяти достигает O(1) от количества живых объектов.

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

class some_function_t
{
   SymbolTable parentScope;
   
   // ...
}

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

Что дальше?

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

Комментарии?

Если у вас есть какие-то вопросы, комментарии или предложения, отправляйте их в coffemug@gmail.com Я буду рад услышать ваше мнение.


Эта статья опубликована в журнале RSDN Magazine #2-2006. Информацию о журнале можно найти здесь