Многопоточность
Функциональная программа сразу готова к распараллеливанию без каких-либо
изменений. Вам не придётся задумываться о deadlock-ах или состояниях
гонки (race conditions) потому что вам не нужны блокировки! Ни один
кусочек данных в функциональной программе не меняется дважды одним и тем
же потоком или разными. Это означает, что вы можете легко добавить
потоков к вашей программе даже не задумываясь при этом о проблемах,
присущих императивным языкам.
Если дела обстоят подобным образом, то почему так редко функциональные
языки программирования используются в многопоточных приложениях? На
самом деле чаще, чем вы думаете. Компания Ericsson разработала
функциональный язык под названием Erlang
для использования на отказоустойчивых и масштабируемых
телекоммуникационных коммутаторах. Многие отметили преимущества Erlang-а
и стали его использовать.
Мы говорим о телекоммуникациях и системах контроля трафика, которые
далеко не так просто масштабируются, как типичные системы, разработанные
на Wall Street. Вообще-то, системы написанные на Erlang, не такие
масштабируемые и надёжные, как Java системы. Erlang системы просто
сверхнадёжные.
На этом история многопоточности не заканчивается. Если вы пишете по сути
однопоточное приложение, то компилятор всё равно может оптимизировать
функциональную программу так, чтобы она использовала несколько CPU.
Посмотрим на следующий кусок кода.
String s1 = somewhatLongOperation1();
String s2 = somewhatLongOperation2();
String s3 = concatenate(s1, s2);
Компилятор функционального языка может проанализировать код, классифицировать функции, которые создают строки s1 и s2 ,
как функции потребляющие много времени, и запустить их параллельно. Это
невозможно сделать в императивном языке, потому что каждая функция
может изменять внешнее состояние и код, идущий непосредственно после
вызова, может зависеть от неё. В ФП автоматический анализ функций и
поиск подходящих кандидатов для распараллеливания — это тривиальнейшая
задача, как автоматический inline ! В этом смысле
функциональный стиль программирования соответствует требованиям
завтрашнего дня. Разработчики железа уже не могут заставить CPU работать
быстрее. Вместо этого они наращивают количество ядер и заявляют о
четырёхкратном увеличении скорости многопоточных вычислений. Конечно они
очень вовремя забывают сказать, что ваш новый процессор покажет прирост
только в программах, разработанных с учётом распараллеливания. Среди
императивного ПО таких очень мало. Зато 100% функциональных программ
готовы к многопоточности из коробки.
Развёртывание по горячему
В старые времена для установки обновлений Windows приходилось
перезагружать компьютер. Много раз. После установки новой версии медиа
проигрывателя. В Windows XP произошли значительные изменения, но
ситуация всё ещё далека от идеальной (сегодня я запустил Windows Update
на работе и теперь надоедливое напоминание не оставит меня в покое, пока
не перезагружусь). В Unix системах модель обновления была получше. Для
установки обновлений приходилось останавливать некоторые компоненты, но
не всю ОС. Хотя ситуация выглядит лучше, но для большого класса
серверных приложений это всё ещё не приемлемо. Телекоммуникационные
системы должны быть включены 100% времени, ведь если из-за обновления
человек не сможет вызвать скорую, то жизни могут быть потеряны. Фирмы с
Wall Streets тоже не желают останавливать сервера на выходных, чтобы
установить обновления.
В идеале нужно обновить все нужные участки кода не останавливая систему в
принципе. В императивном мире это невозможно [пер. в Smalltalk-е очень
даже возможно]. Представьте себе выгрузку Java класса на лету и
перезагрузка новой версии. Если бы мы так сделали, то все экземпляры
класса стали бы нерабочими, потому что потерялось бы состояние, которое
они хранили. Нам пришлось бы писать хитрый код, для контроля версий.
Пришлось бы серриализовать все созданные экземпляры класса, потом
уничтожить их, создать экземпляры нового класса, попытаться загрузить
серриализованные данные в надежде, что миграция пройдёт нормально и
новые экземпляры будут валидными. И кроме того, миграционный код
необходимо писать каждый раз вручную. И ещё миграционный код должен
сохранять ссылки между объектами. В теории ещё куда ни шло, но на
практике это никогда не заработает.
В функциональной программе всё состояние хранится в стеке в виде
аргументов функций. Это позволяет значительно упростить развёртывание по
горячему! По сути всё что нужно сделать — это вычислить разницу между
кодом на рабочем сервере и новой версией, и установить изменения в коде.
Остальное будет сделано языковыми инструментами автоматически! Если вы
думаете, что это научная фантастика, то дважды подумайте. Инженеры,
имеющие дело с Erlang, годами обновляют свои системы без остановки их работы.
Доказательные вычисления и оптимизация (Machine Assisted Proofs and Optimizations)
Еще одно интересное свойство функциональных языков программирования
состоит в том, что их можно изучать с математической точки зрения. Так
как функциональный язык — это реализация формальной системы, то все
математические операции используемые на бумаге, могут быть применены и к
функциональным программам. Компилятор, например, может конвертировать
участок кода в эквивалентный, но более эффективный кусок, при этом
математически обосновав их эквивалентность [7].
Реляционные базы данных годами производят такие оптимизации. Ничто не
мешает использовать аналогичные приёмы в обычных программах.
Дополнительно вы можете использовать математический аппарат, чтобы
доказать корректность участков ваших программ. При желании можно
написать инструменты, которые анализируют код и автоматически создают
Unit-тесты для граничных случаев! Такая функциональность бесценна для
сверхнадёжных систем (rock solid systems). При разработке систем
контроля кардиостимуляторов или управления воздушным трафиком такие
инструменты просто необходимы. Если же ваши разработки не находятся в
сфере критически важных приложений, то инструменты автоматической
проверки всё равно дадут вам гигантское преимущество перед вашими
конкурентами.
Функции высшего порядка
Помните, когда я говорил о преимуществах ФП, я отметил, что «всё
выглядит красиво, но бесполезно, если мне придётся писать на корявом
языке, в котором всё final ». Это было заблуждением. Использование final
повсеместно выглядит коряво только в императивных языках
программирования, таких как Java. Функциональные языки программирования
оперируют другими видами абстракций, такими, что вы забудете о том, что
когда-то любили менять переменные. Один из таких инструментов — это
функции высшего порядка.
В ФП функция — это не тоже самое, что функция в Java или C. Это
надмножество — они могут тоже самое, что Java функции и даже больше.
Пусть у нас есть функция на C:
int add(int i, int j) {
return i + j;
}
В ФП это не тоже самое, что обычная C функция. Давайте расширим наш Java
компилятор, чтобы он поддерживал такую запись. Компилятор должен
превратить объявление функции в следующий Java код (не забывайте, что
везде присутствует неявный final ):
int add(int i, int j) {
return i + j;
}
}
add_function_t add = new add_function_t();
Символ add не совсем функция. Это маленький класс с одним методом. Теперь мы можем передавать add в качестве аргумента в другие функции. Мы можем записать его в другой символ. Мы можем создавать экземпляры add_function_t
в runtime и они будут уничтожены сборщиком мусора, если станут
ненужными. Функции становятся базовыми объектами, как числа и строки.
Функции, которые оперируют функциями (принимают их в качестве
аргументов) называются функциями высшего порядка. Пусть это вас не
пугает. Понятие функций высшего порядка почти не отличается от понятия
Java классов, которые оперируют друг другом (мы можем передавать классы в
другие классы). Мы можем называть их «классы высшего порядка», но никто
этим не заморачивается, потому что за Java не стоит строгое
академическое сообщество.
Как и когда нужно использовать функции высшего порядка? Я рад, что вы
спросили. Вы пишите свою программу как один большой монолитный кусок
кода не заботясь об иерархии классов. Если вы увидите, что какой-то
участок кода повторяется в разных места, вы выносите его в отдельную
функцию (к счастью в школах еще учат как это делать). Если вы замечаете,
что часть логики в вашей функции должна вести себя по разному в
некоторых ситуациях, то вы создаёте функцию высшего порядка. Запутались?
Вот реальный пример из мой работы.
Предположим, что у нас есть участок Java кода, который получает
сообщение, преобразует его различными способами и передаёт на другой
сервер.
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);
Мы не создавали новых типов и не усложняли иерархию классов. Мы просто
передали функцию в качестве параметра. Мы достигли того же эффекта, как и
в объектно-ориентированном аналоге, только с некоторыми преимуществами.
Мы не привязывали себя к какой-либо иерархии классов: мы можем
передавать любые другие функции в runtime и менять их в любой момент,
сохраняя при этом высокий уровень модульности меньшим количеством кода.
По сути компилятор создал объектно-ориентированный «клей» вместо нас!
При этом сохраняются все остальные преимущества ФП. Конечно абстракции,
предлагаемые функциональными языками на этом не заканчиваются. Функции
высшего порядка это только начало
Каррирование
Большинство людей, с которыми я встречаюсь, прочли книгу «Паттерны проектирования»
Банды Четырёх. Любой уважающий себя программист будет говорить, что
книга не привязана к какому-либо конкретному языку программирования, а
паттерны применимы к разработке ПО в целом. Это благородное заявление.
Но к сожалению оно далеко от истины.
Функциональные языке невероятно выразительны. В функциональном языке вам
не понадобятся паттерны проектирования, потому что язык настолько
высокоуровневый, что вы легко начнёте программировать в концепциях,
которые исключают все известные паттерны программирования. Одним из
таких паттернов является Адаптер (чем он отличается от Фасада? Похоже,
что кому-то понадобилось наштамповать побольше страниц, чтобы выполнить
условия контракта). Этот паттерн оказывается ненужным если в языке есть
поддержки каррирования.
Паттерн Адаптер наиболее часто применяется к «стандартной» единице
абстракции в Java — классу. В функциональных языках паттерн применяется к
функциям. Паттерн берёт интерфейс и преобразует его в другой интерфейс,
согласно определённым требованиям. Вот пример паттерна Адаптер:
int pow(int i, int j);
int square(int i)
{
return pow(i, 2);
}
Этот код адаптирует интерфейс функции, возводящей число в произвольную
степень, к интерфейсу функции, которая возводит число в квадрат. В
аккадемических кругах этот простейший приём называется каррирование (в
честь специалиста по логике Хаскелла Карри (Haskell Curry), который
провёл ряд математических трюков, чтобы всё это формализовать). Так как в
ФП функции используются повсеместно в качестве аргументов, каррирование
используется очень часто, чтобы привести функции к интерфейсу,
необходимому в том или ином месте. Так как интерфейс функции — это её
аргументы, то каррирование используется для уменьшения количества
аргументов (как в примере выше).
Этот инструмент является встроенным в функциональные языки. Вам не нужно
вручную создавать функцию, которая оборачивает оригинал. Функциональный
язык сделает всё за вас. Как обычно давайте расширим наш язык, добавив в
него каррирование.
square = int pow(int i, 2);
Этой строкой мы автоматически создаём функцию возведения в квадрат с одним аргументом. Новая функция будет вызывать функцию pow , подставляя 2 в качестве второго аргумента. С точки зрения Java, это будет выглядеть следующим образом:
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 . Если вместо concatenate
мы подставим функцию, которая в зависимости от условия использует один
аргумент из двух, то второй аргумент можно даже не вычислять! Haskell
— это пример языка с отложенными вычислениями. В Haskell отсутствует
гарантия какой-либо очередности вызовов (вообще!), потому что Haskell
выполняет код по мере необходимости.
Ленивые вычисления обладают рядом достоинств как и некоторыми
недостатками. В следующем разделе мы обсудим достоинства и я объясню как
уживаться с недостатки.
Оптимизация
Ленивые вычисления обеспечивают громадный потенциал для оптимизаций.
Ленивый компилятор рассматривает код в точности как математик изучает
алгебраические выражения — он может отменять некоторые вещи, отменять
выполнение тех или иных участков кода, менять очерёдность вызовов для
большей эффективности, даже располагать код таким образом, чтобы
уменьшить количество ошибок, при этом гарантируя целостность программы.
Это самое большое преимущество при описании программы строгими
формальными примитивами — код подчиняется математическим законам и может
быть изучен математическими методами.
Абрстрагирование структур управления
Ленивые вычисления обеспечивают настолько высокий уровень абстракций,
что становятся возможными удивительные вещи. Например представим себе
реализацию следующей управляющей структуры:
unless(stock.isEuropean()) {
sendToSEC(stock);
}
Мы хотим, чтобы функция sendToSEC выполнялась только если фонд (stock) не европейский. Как можно реализовать unless ?
Без ленивый вычислений нам бы понадобилась система макросов, но в
языках, подобных Haskell, это не обязательно. Мы можем объявить unless в виде функции!
void unless(boolean condition, List code) {
if(!condition)
code;
}
Заметьте, что code не будет выполняться, если condition == true . В строгих языках такое поведение невозможно повторить, так как аргументы будут вычислены прежде, чем unless будет вызвана.
Бесконечные структуры данных
Ленивые языки позволяют создавать бесконечные структуры данных, создание
которых в строгих языках гораздо сложнее [пер. — только не в Python].
Например представьте себе последовательность Фибоначи. Очевидно, что мы
не может вычислить бесконечный список за конечное время и при этом
сохранить его в памяти. В строгих языках, таких как Java, мы просто
написали бы функцию, которая возвращает произвольный член
последовательности. В языках подобных Haskell мы можем абстрагироваться и
просто объявить бесконечный список чисел Фибоначи. Так как язык
ленивый, то будут вычислены лишь необходимые части списка, которые
реально используются в программе. Это позволяет абстрагироваться от
большого числа проблем и посмотреть на них с более высокого уровня
(например можно использовать функции обработки списков на бесконечных
последовательностях).
Недостатки
Конечно бесплатный сыр бывает только в мышеловке. Ленивые вычисления
тянут за собой ряд недостатков. В основном это недостатки от лени. В
реальности очень часто нужен прямой порядок вычислений. Возьмём,
например, следующий код:
System.out.println("Please enter your name: ");
System.in.readLine();
В ленивом языке никто не гарантирует, что первая строка выполнится
раньше второй! Это означает, что мы не можем делать ввод-вывод, не можем
нормально использовать нативные функции (ведь их нужно вызывать в
определённом порядке, чтобы учитывать их побочные эффекты), и не можем
взаимодействовать с внешним миром! Если мы введём механизм для
упорядочивания выполнения кода, то потеряем преимущество математической
строгости кода (а следом потеряем все плюшки функционального
программирования). К счастью ещё не всё потеряно. Математики взялись за
работу и придумали несколько приёмов для того, чтобы убедится в
правильном порядке выполняемых инструкций не потеряв функционального
духа. Мы получили лучшее от двух миров! Такие приёмы включают в себя
продолжения (continuation), монады (monads) и однозначная типизация
(uniqueness typing). В данной статье мы поработаем с продолжениями, а
монады и однозначную типизацию отложим до следующего раза. Занятно, что
продолжения очень полезная штука, которая используется не только для
задания строгого порядка вычислений. Об этом мы тоже поговорим.
Продолжения
Продолжения в программировании играют такую же роль, как «Код да Винчи» в
человеческой истории: удивительное разоблачение величайшей тайны
человечества. Ну, может не совсем так, но они точно срывают покровы, как
в своё время вы научились брать корень из -1.
Когда мы рассматривали функции мы изучили лишь половину правда, ведь мы
исходили из предположения, что функция возвращает значение в вызывающую
её функцию. В этом смысле продолжение — это обобщение функций. Функция
не обязательно должна возвращать управление в то место, откуда её
вызвали, а может возвращать в любое место программы. «Продолжение» — это
параметр, который мы может передать в функцию, чтобы указать точку
возврата. Звучит намного страшнее, чем есть на самом деле. Давайте
взглянем на следующий код:
int i = add(5, 10);
int j = square(i);
Функция add возвращает число 15, которое записывается в i , в том месте, где функция и была вызвана. Затем значение i используется при вызове square .
Заметьте, что ленивый компилятор не может поменять очередность
вычислений, ведь вторая строка зависит от результата первой. Мы можем
переписать этот код с использованием Стиль Передачи Продолжения
(Continuation Passing Style или CPS), когда add возвращает значение в функцию square .
int j = add(5, 10, square);
В таком случае add получает дополнительный аргумент — функцию, которая будет вызвана после того, как add закончит работать. В обоих примерах j будет равен 225.
В этом и заключается первый приём, позволяющий задать порядок выполнения
двух выражений. Вернёмся к нашему примеру с вводом-выводом
System.out.println("Please enter your name: ");
System.in.readLine();
Эти две строки не зависят друг от друга, и компилятор волен поменять их
порядок по своему хотению. Но если мы перепишем в CPS, то тем самым
добавим нужную зависимость, и компилятору придётся проводить вычисления
одно за другим!
System.out.println("Please enter your name: ", System.in.readLine);
В таком случае println должен будет вызвать readLine , передав ему свой результат, и вернуть результат readLine в конце. В таком виде мы можем быть уверены, что эти функции будут вызваны по очереди, и что readLine вообще вызовется (ведь компилятор ожидает получить результат последней операции). В случае Java println возвращает void . Но если бы возвращалось какое-либо абстрактное значение (которое может служить аргументом readLine ),
то это решило бы нашу проблему! Конечно выстраивание таких цепочек
функций сильно ухудшает читаемость кода, но с этим можно бороться. Мы
можем добавить в наш язык синтаксических плюшек, которые позволят нам
писать выражения как обычно, а компилятор автоматически выстраивал бы
вычисления в цепочки. Теперь мы можем проводить вычисления в любом
порядке, не потеряв при этом достоинств ФП (включая возможность
исследовать программу математическими методами)! Если вас это сбивает с
толку, то помните, что функции — это всего лишь экземпляры класса с
единственным членом. Перепишите наш пример так, чтобы println и readLine были экземплярами классов, так вам станет понятней.
Но на этом польза продолжений не заканчивается. Мы можем написать всю
программу целиком используя CPS, чтобы каждая функция вызывалась с
дополнительным параметром, продолжением, в которое передаётся результат.
В принципе любую программу можно перевести на CPS, если воспринимать
каждую функцию как частный случай продолжений. Такое преобразование
можно произвести автоматически (в действительности многие компиляторы
так и делают).
Как только мы переведём программу к CPS виду, становится ясно, что у
каждой инструкции есть продолжение, функция в которую будет передаваться
результат, что в обычной программе было бы точкой вызова. Возьмём любую
инструкцию из последнего примера, например add(5,10) . В программе, написанной в CPS виде, понятно что будет являться продолжением — это функция, которую add
вызовет по окончанию работы. Но что будет продолжением в случае не-CPS
программы? Мы, конечно, можем конвертировать программу в CPS, но нужно
ли это?
Оказывается, что в этом нет необходимости. Посмотрите внимательно на
наше CPS преобразование. Если вы начнёте писать компилятор для него, то
обнаружите, что для CPS версии не нужен стек! Функции никогда ничего не
возвращают, в традиционном понимании слова «return», они просто вызывают
другую функцию, подставляя результат вычислений. Отпадает необходимость
проталкивать (push) аргументы в стек перед каждым вызовом, а потом
извлекать (pop) их обратно. Мы можем просто хранить аргументы в
каком-либо фиксированном участке памяти и использовать jump
вместо обычного вызова. Нам нет нужны хранить первоначальные аргументы,
ведь они больше никогда не понадобятся, ведь функции ничего не
возвращают!
Таким образом, программы в CPS стиле не нуждаются в стеке, но содержат
дополнительный аргумент, в виде функции, которую нужно вызвать.
Программы в не-CPS стиле лишены дополнительного аргумента, но используют
стек. Что же хранится в стеке? Просто аргументы и указатель на участок
памяти, куда должна вернуться функция. Ну как, вы уже догадались? В
стеке храниться информация о продолжениях! Указатель на точку возврата в
стеке — это то же самое, что и функция, которую нужно вызвать, в CPS
программах! Чтобы выяснить, какое продолжение у add(5,10) , достаточно взять из стека точку возврата.
Это было не трудно. Продолжение и указатель на точку возврата — это
действительно одно и то же, только продолжение указывается явно, и по
этому оно может отличаться от того места, где функция была вызвана. Если
вы помните, что продолжение — это функция, а функция в нашем языке
компилируется в экземпляр класса, то поймёте, что указатель на точку
возврата в стеке и указатель на продолжение — это в действительности
одно и то же, ведь наша функция (как экземпляр класса) — это всего лишь
указатель. А значит, что в любой момент времени в вашей программы вы
можете запросить текущее продолжение (по сути информацию из стека).
Хорошо, теперь мы уяснили, что же такое текущее продолжение. Что это
значит? Если мы возьмём текущее продолжение и сохраним его где-нибудь,
мы тем самым сохраним текущее состояние программы — заморозим её. Это
похоже на режим гибернации ОС. В объекте продолжения хранится
информация, необходимая для возобновления выполнения программы с той
точки, когда был запрошен объект продолжения. Операционная система
постоянно так делает с вашими программами, когда переключает контекст
между потоками. Разница лишь в том, что всё находится под контролем ОС.
Если вы запросите объект продолжения (в Scheme это делается вызовом
функции call-with-current-continuation ), то вы получите
объект с текущим продолжением — стеком (или в случае CPS — функцией
следующего вызова). Вы можете сохранить этот объект в переменную (или
даже на диск). Если вы решите «перезапустить» программу с этим
продолжением, то состояние вашей программы «преобразуется» к состоянию
на момент взятия объекта продолжения. Это то же самое, как переключение к
приостановленному потоку, или пробуждение ОС после гибернации. С тем
исключением, что вы можете проделывать это много раз подряд. После
пробуждения ОС информация о гибернации уничтожается. Если этого не
делать, то можно было бы восстанавливать состояние ОС с одной и той же
точки. Это почти как путешествие по времени. С продолжениями вы можете
себе такое позволить!
В каких ситуациях продолжения будут полезны? Обычно если вы пытаетесь
эмулировать состояние в системах лишенных такового по сути. Отличное
применение продолжения нашли в Web-приложениях (например во фреймворке Seaside
для языка Smalltalk). ASP.NET от Microsoft прикладывает огромные
усилия, чтобы сохранять состояние между запросами, и облегчить вам
жизнь. Если бы C# поддерживал продолжения, то сложность ASP.NET можно
было бы уменьшить в два раза — достаточно было бы сохранять продолжение и
восстанавливать его при следующем запросе. С точки зрения
Web-программиста не было бы ни единого разрыва — программа продолжала бы
свою работу со следующей строки! Продолжения — невероятно полезная
абстракция для решения некоторых проблем. Учитывая то, что всё больше и
больше традиционных толстых клиентов перемещаются в Web, важность
продолжений будет со временем только расти.
|