REBOL: Почти функциональное программирование
Рабочие материалы по почти функциональному и почти объектному языку программирования

Почти функциональное программирование

© Вячеслав Ахмечет — оригинальная статья
© Николай Линкер — перевод и правки
© «Бакава» Анохин — рефакторинг и новый контекст

Содержание: 1. Введение
2. Функциональное программирование
2.1. Юнит-тестирование
2.2. Отладка
2.3. Параллельные вычисления
2.4. Оптимизация
2.5. Изменение кода на лету
2.6. Машинные доказательства и оптимизация
2.7. Функции высшего порядка
2.8. Карринг
2.9. Ленивые вычисления
2.10. Продолжения
3. Что дальше?

1. Введение

Уже написано много-много статей о функциональном программировании, зачем же плодить сущности? Хотя бы затем, что

а) хороших статей немного,
б) ФП в Rebolе чуточку отличается от академического ФП,
в) первоисточник этой статьи и без меня был хорош и правилен, а я вдобавок постараюсь сделать недурный микс — короткий, актуальный и ненадоедливый.

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

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

Лямбда-исчисление -- это правила построения и вычисления функций (в том числе неименованных). Функциональное программирование основано на Лямбда-исчислении и, как нетрудно догадаться, использует функции для выполнения вычислений. Функции – это краегольный камень функционального программирования. Они используются почти везде, даже в простейших вычислениях. Даже переменные – и те заменены функциями. Точнее говоря, в функциональном программировании переменные – это просто псевдонимы для полноценных выражений. В традиционных функциональных языках переменные вообще нельзя изменять: они могут принимать значения только один раз. Rebol не относится к традиционным функциональным языкам, и потому больших сложностей при работе с переменными не предвидится.

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

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

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

rever: func [ str ] [
  either 0 = length? str 
    [return str]
    [return join rever next str str/1]
]

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

time-block [ reverse "123456" ] 0.05
== 1.54822635650635E-6
time-block [ rever "123456" ] 0.05  
== 0.00020118408203125

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

2.1. Возможность юнит-тестирования

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

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

В Rebol юнит-тестирование работает сносно до тех пор, пока программист не трогает глобальных переменных. Можно играть по правилам, а можно их нарушать: всему своё место.

2.2. Отладка

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

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

Диджей этой статьи использует интенсивное тестирование при помощи модуля NeWt: для каждой функции пишется простой тест, проверяющий её корректность. Если вы написали новую функцию, и она как-то влияет на старую — тесты сразу же выявят этот непорядок.

2.3. Параллельные вычисления

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

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

Примечание:

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

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

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

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

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

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

2.4. Оптимизация

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

s1: длинное-вычисление-в-результате-строка1
s2: длинное-вычисление-в-результате-строка2
s3: join s1 s2

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

Уф. Жду реализацию такого распараллеливания в Реболе.

2.5. Изменение кода на лету

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

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

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

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

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

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

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

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

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

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

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

add: func [ i j ][return i + j ]

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

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

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

handle-message: func [msg][
    // ...
    set-client-code msg "ABCD_123"
    // ...
    send-message msg
    // ...
]

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

handle-message: func [msg][
    // ...
    either destination == "server1" 
        [ set-client-code msg "ABCD_123" ]
        [ set-client code msg "123_ABC" ]
    // ...
    send-message msg
  // ...
]

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

handle-message: func [msg][
    set-client-code1: does [ set-client-code msg "ABCD_123" ]
    set-client-code2: does [ set-client-code msg "123_ABCD" ]
    // ...
    either destination == "server1"
        [ set-client-code1 ]
        [ set-client-code2 ]
    send-message msg
  // ...
]

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

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

2.8. Карринг (Currying)

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

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

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

square: [i][
  return power i  2
]

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

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

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

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

s1: длинное-вычисление-в-результате-строка1
s2: длинное-вычисление-в-результате-строка2
s3: join s1 s2

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

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

Увы, в реболе ленивые вычисления не прижились, вернее, прижились частично — в логических выражениях.

Машинная оптимизация

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

Абстрактные структуры управления

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

unless isEuropean [ sendToSEC stock]

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

unless: func [ boo [logic!] blo [block!] ][
    if not boo [ do blo ]
]

Теперь можно спокойно выполнять конструкции вида

unless you-are-jerk [ feel yourself lucky]

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

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

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

К сожалению, бесконечных списков в реболе построить (пока) не получится.

Недостатки

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

prin "Please enter your name: "
name: input

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

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

2.10. Продолжения

В Rebol продолжения не играют очень уж важной роли — просто потому, что порядок выполнения определён довольно жёстко (за что и ругают Rebol поклонники чисто функциональных языков).

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

i: add 5 10
j: square i

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

j: square i: add 5 10 

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

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

3. Что дальше?

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


Formatted with MakeDoc2 by REBOL and with hands by Bakava - 26-June-2007