Это совершенно не так. Да, функции в функциональных языках действительно не должны содержать side-эффектов, но нигде ведь не сказано, что среда, выполняющая код, не может взаимодействовать с внешним миром!
Давайте используем этот факт, и напишем пример программы, выполняющую чистофункциональный консольный ввод/вывод. В качестве языка будем использовать обычную Java 8, чтобы императивные программисты не думали, что для такого обязательно нужно иметь на руках Haskell.
Итак, основная идея заключается в том, что функция взаимодействия с внешним миром, будет содержать не код, выполняющий набор императивных команд с side-эффектами, а будет возвращать функциональное выражение, описывающее это взаимодействие.
Для этого заведём интерфейс IO:
interface IO<A> {
A run() throws IOException;
}
Интерфейс IO следует понимать так: если у меня есть некоторый объект, реализующий IO, то этот объект ‒ инкапсуляция некоторого взаимодействия с внешним миром (например, чтение строки из консоли). Логика взаимодействия находится в методе run(), и реальные side-эффекты начнут происходить только когда метод run() вызовется. Сам же по себе объект IO ‒ это просто ссылка, которую можно передавать в качестве аргумента методов или возвращать из методов, не нарушая чистоты этих методов!
Вот так будет выглядеть реализация методов, которые инкапсулируют вывод в консоль и чтение из неё:
interface IO<A> {
A run() throws IOException;
static IO<Void> printLine(String str) {
return () -> { System.out.println(str); return null; };
}
static IO<String> readLine() {
return () -> new BufferedReader(new InputStreamReader(System.in)).readLine();
}
}
Мы добавили два метода, которые создают объекты IO, описывающие вывод и чтение строки. Так как вывод не возвращает никакого осмысленного значения, то IO параметризован классом Void, который является материализованной версией ключевого слова void в Java. Заметьте, в данном коде мы использовали новую фичу, появившуюся в Java 8 ‒ возможность писать статические методы в интерфейсах. Это очень классная фича, я считаю.
Теперь, наконец, напишем чистофункциональный Hello, world:
public static void main(String[] args) throws IOException {
mainPure().run();
}
public static IO<Void> mainPure() {
return IO.printLine("Hello,
world!");
}
mainPure() ‒ это чистая функция, которая возвращает выражение, описывающее печатание строки Hello, world! в консоль. А main() ‒ это среда выполнения, которая уже непосредственно исполняет это выражение и осуществляет side-эффекты.
Хорошо. Но пока, похоже, мы сильно ограничены в экспрессивности нашего языка. Что должна вернуть функция mainPure(), если мы захотим, например, написать echo-программу, читающая строку, а потом просто печатающая её обратно в консоль?
Не вопрос! Только сначала нужно реализовать один очень важный комбинатор под названием flatMap():
interface IO<A> {
A run() throws IOException;
default IO<B> flatMap(Function<A, IO<B>> f) {
return () -> f.apply(run()).run();
}
}
Смысл flatMap() в том, что он возвращает объект IO, являющийся результатом выполнения первого IO, а потом над результатом выполняется другой IO. Заметьте, что в этом коде мы тоже использовали ещё одну новую классную фичу Java 8 ‒ default-методы.
Теперь echo-программа будет выглядеть так:
public static IO<Void> mainPure() {
return IO.readLine().flatMap(IO::printLine);
}
Как коротко, правда? Почти не уступает аналогичному императивному коду.
В IO можно добавлять сколько угодно фабричных методов и комбинаторов. Добавим очень важный метод immediate(), который просто немедленно возвращает значение без всяких side-эффектов, и комбинатор map():
interface IO<A> {
A run() throws IOException;
default IO<B> map(Function<A, B> f) {
return () -> f.apply(run());
}
default IO<B> flatMap(Function<A, IO<B>> f) {
return () -> f.apply(run()).run();
}
return () -> a;
}
}
Теперь можно реализовать простейший калькулятор, который считывает два числа и печатает их сумму:
public static IO<Void> mainPure() {
return IO.readLine().map(Double::valueOf).flatMap(num1 ->
IO.readLine().map(Double::valueOf).flatMap(num2 ->
IO.printLine("Sum of " + num1 + " " + num2 + " = " + (num1 + num2))
));
}
Расширим IO ещё дальше и добавим поддержку исключений! Добавим метод except(), который выполняет обработчик в случае исключения (аналог императивного catch), и метод onException(), который гарантированного выполняет блок кода, даже если вылетело исключение (аналог императивного finally):
interface IO<A> {
...
default IO<A> except(Function<Throwable, IO<B>> handler) {
return () -> {
try {
return run();
} catch (Throwable t) {
return handler.apply(t).run();
}
};
}
default <B> IO<A> onException(IO<B> action) {
return () -> {
try {
return run();
} catch (Throwable t) {
action.run();
throw t;
}
};
}
}
Расширять API интерфейса IO можно до бесконечности. Можно добавить условия, циклы, автоматическое закрывание ресурсов и т.д. Например, добавим цикл until:
default IO<A> until(Predicate<A> p) {
return flatMap(a -> p.test(a) ? immediate(a) : until(p));
}
Используя этот цикл, можно написать простейший REPL, который либо считывает число, умножает его на 2 и продолжает дальше, либо если встречает слово quit, то выходит:
enum Command {
CONTINUE, QUIT
}
public static IO<Command> mainPure() {
IO<Command> command = IO.readLine().flatMap(str -> {
if (str.equals("quit")) {
return IO.immediate(Command.QUIT);
} else {
return IO.immediate(Double.valueOf(str)).flatMap(num ->
IO.printLine(num + " * 2 = " + Double.toString(num * 2)).flatMap(unit ->
IO.immediate(Command.CONTINUE)));
}
});
return command.until(c -> c == Command.QUIT);
}
Таким образом, расширив API до нужно уровня, можно построить полноценный каркас для написания программ, взаимодействующих с внешним миром. Сам каркас, конечно, кое-где внутри будет использовать побочные эффекты, но его использование со стороны будет полностью функциональным. Мы строим программу, соединяя примитивные выражения в более сложные, а в конце одно большое выражение возвращается методом mainPure(), которое затем выполняется Java-машиной.
Важно отметить, что вышеописанная реализация IO является очень примитивной и неэффективной. Например, в этой реализации никак не учитывается переполнение стека. Поэтому, если возникнет желание написать чистофункциональный production-код с внешними эффектами, то лучше найти готовую библиотеку. Хотя пока я ничего конкретного посоветовать не могу. Вроде как возобновилась работа над развитием библиотеки Functional Java (после того как все авторы переключились на Scalaz), но я пока не исследовал, в каком она состоянии и насколько удачно там применяются фичи Java 8.
Важно отметить, что вышеописанная реализация IO является очень примитивной и неэффективной. Например, в этой реализации никак не учитывается переполнение стека. Поэтому, если возникнет желание написать чистофункциональный production-код с внешними эффектами, то лучше найти готовую библиотеку. Хотя пока я ничего конкретного посоветовать не могу. Вроде как возобновилась работа над развитием библиотеки Functional Java (после того как все авторы переключились на Scalaz), но я пока не исследовал, в каком она состоянии и насколько удачно там применяются фичи Java 8.
Комментариев нет:
Отправить комментарий