Статьи

Если бы Java была Haskell

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

Если вы хотите изучать Haskell, я рекомендую начать с замечательного «  Learn You a Haskell for Great Good» . Это введение в Haskell действительно приятно читать благодаря подробным объяснениям и нахальному юмору, и оно доступно онлайн бесплатно.

Очень часто люди рекомендуют разработчикам ОО забыть все, что они знают, чтобы понять концепции функционального программирования. Я даже видел термин «разучиться» в некоторых местах. Это может быть мудрым советом, но когда я узнал о Haskell, я не мог не сравнить его с Java. Давайте посмотрим, насколько нам понадобится растянуть язык Java, чтобы сделать его Haskell.

переменные

Первое, что мешает Java стать Haskell, — это его постоянная изменчивость. Это относительно легко, просто представьте, что каждая переменная должна быть конечной. Другими словами, каждая переменная становится константой в данной области видимости. Эта область может быть классом, объектом или просто локальной для метода.

После этого все, что программист может сделать с переменной, связывает ее со значением только один раз, а затем читает столько раз, сколько необходимо. Интересным следствием является то, что объекты можно создавать, но никогда не изменять, они неизменны. Чтобы сделать Java Haskell, просто представьте, что по умолчанию используется неизменность, и другого пути нет. И это верно даже для локальных переменных, так что больше нет людей с ++.

функции

Haskell — это чисто функциональный язык, это означает, что все можно представить с точки зрения ввода и вывода, как с математическими функциями. Первое, что приходит на ум разработчику Java, — это сходство со статическими методами. Если вы принимаете как должное полную упомянутую выше неизменность, статические методы фактически становятся чистыми функциями (если они не содержат побочных эффектов ввода / вывода, что является другой историей).

Теперь нам нужно немного усилить эти статические методы. Возможно, вам известно о  Project Lambda,  который будет выводить лямбда-выражения в Java 8. В основном это позволит методам принимать другие методы в качестве параметров. Другими словами, вместо того, чтобы принимать только входные значения, метод сможет принимать в качестве параметра некоторый код для выполнения. Обратите внимание, что формально говоря лямбда-выражение является анонимной функцией, однако Java 8 предложит специальную конструкцию, позволяющую отправлять реальный именованный метод в качестве параметра для другого метода. Таким образом, мы получим больше, чем просто лямбда-выражения.

Для преодоления разрыва с функциями более высокого порядка, которые предлагают Haskell и другие функциональные языки, нам также нужна возможность вернуть метод. Насколько я знаю, это также будет возможно в Java 8 путем инкапсуляции возвращаемого кода в Runnable или Callable. Таким образом, Java 8 в значительной степени допускает функции высшего порядка.

пример

Хотите верьте, хотите нет, но с полной неизменяемостью и функциями высшего порядка Java станет функциональным языком. Объекты просто станут структурами данных — я расскажу об этом в другом посте — и императивные конструкции, такие как циклы, станут бесполезными. Подумайте об этом, даже если мы придерживаемся языка, как много вы можете с этим сделать, если не можете ничего мутировать? Нада! Вместо этого вам нужно идти функциональным путем, то есть рекурсией. Например, чтобы суммировать целые числа в массиве без изменения переменной, нам нужно сделать что-то вроде этого:

int sum(int[] values) {
    return doSum(values, 0, 0);
}

int doSum(int[] values, int index, int accumulator) {
    if (index < values.length) {
        return doSum(values, index+1, accumulator+values[index]);
     }
     return accumulator;
}

The code above requires to pass the current index to look up. To avoid that we would need a way to extract the tail of the array. One way would be to wrap the array into an object also containing the current index and use this object instead of the plain array. That’s how the rest function (lisp name for tail) is implemented internally in Clojure. But in order to bring more Haskell flavor into our mutant Java, we will introduce some syntactic sugar called destructuring, which allows to automatically split an argument into multiple variables. Here is how it can transform our code:

int sum(int[] values) {
     return doSum(values, 0);
}

// notice how values is decomposed into head and tail
int doSum(int:int[] head:tail, int accumulator) {
    if (tail.length > 0) {
        return doSum(tail, accumulator+head);
    }
    return accumulator;
}

In this code, the function applied between the accumulator and each value is hard-coded, it’s ‘+’. Thanks to higher-order functions, we can replace it with any 2-argument function that we send as a parameter. Let’s do this and rename doSum into comprehend.

int sum(int[] values) {
    return comprehend(values, 0, (int x, int y) => x + y);
}

int comprehend(int:int[] head:tail, int accumulator, Runnable function) {
    if (tail.length > 0) {
       // Note that I couldn't find how Java 8 will allow running a Runnable
       // with some arguments so I made up the apply below
       int newAccumulator = function.apply(accumulator, head);
       return comprehend(tail, newAccumulator, function);
    }
    return accumulator;
}

The next step would be to parameterize the comprehend method to allow for any type of accumulator, not just int. This way we can re-use comprehend to do other things, such as filtering a list. Here I will assume that primitives can be used as parameterized type in our new language.

int sum(int[] values) {
    return <int, int>comprehend(values, 0, (int x, int y) => x + y);
}

String[] filterStartsWith(String[] values, String prefix) {
    // the function below introduces a new syntactic sugar ++ which allows instantiating
    // an array which is the exact copy of x, with y appended to it
    Runnable filter = (String[] x, String y) => (y.startsWith(prefix))? x++y : x};
    return <String, String[]>comprehend(values, new String[0], filter);
}

<E,T> comprehend(E:E[] head:tail, T accumulator, Runnable function) {
    if (tail.length > 0) {
       T newAccumulator = function.apply(accumulator, head);
       return comprehend(tail, newAccumulator, function);
    }
    return accumulator;
}

You can see how we reused our comprehend method for 2 very different things, summing the integers in a list and filtering the strings which start with a given prefix from a list of strings.

There is more…

At this point, we have a functional Java, that’s already a big step. Haskell provides more functional goodness that I won’t expand on. Here is a quick overview:

  • Laziness: functions and variables are not evaluated before they’re actually used. This even allows for infinite lists.
  • Function composition: like in mathematics you can build a function which is a chain of other functions.
  • Pattern matching and Guards to automatically branch code based on an input value (or a destructured input value)
  • where and let bindings to structure the code with temporary variables
  • List comprehension: this is such a common use case that Haskell has syntactic sugar for it, so there is no need of a comprehend function like we made
  • currying: internally every function takes a single argument, when there are 2 or more in your code, the compiler decomposes it into 2 or more functions. It’s easier to understand with an example, the function a + b can be decomposed into a function which takes an argument a and returns a function which adds a to its argument. Then we pass b to this new function. The cool benefit is the ability to pass around some partially applied function.

In my humble opinion, those are just nice secondary features of Haskell. Its true essence is made of the functional paradigm that I tried to cover in this post, and its type system which deserves another post.