понедельник, 20 октября 2014 г.

Чистофункциональный ввод/вывод в Java 8

Любая сколько-нибудь полезная программа обязательно должна как-то взаимодействовать с внешним миром: печатать текст на экран, рисовать фигуры, считывать содержимое файла и т.п. Почему-то в кругу императивных программистов часто бытует мнение, что раз функциональные языки могут использовать только чистые функции, то значит на них нельзя написать программы, взаимодействующие с внешним миром.
Это совершенно не так. Да, функции в функциональных языках действительно не должны содержать 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<BflatMap(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<Bmap(Function<A, Bf) {
        return () -> f.apply(run());
    }

    default IO<BflatMap(Function<A, IO<B>> f) {
        return () -> f.apply(run()).run();
    }

    static IO<Aimmediate(A a) {
        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<Aexcept(Function<Throwable, IO<B>> handler) {
        return () -> {
            try {
                return run();
            } catch (Throwable t) {
                return handler.apply(t).run();
            }
        };
    }

    default <B> IO<AonException(IO<B> action) {
        return () -> {
            try {
                return run();
            } catch (Throwable t) {
                action.run();
                throw t;
            }
        };
    }
}

Расширять API интерфейса IO можно до бесконечности. Можно добавить условия, циклы, автоматическое закрывание ресурсов и т.д. Например, добавим цикл until:

default IO<Auntil(Predicate<Ap) {
    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.