Статьи

Функциональное программирование для Java: начало работы с Javaslang

Ява — это старый язык, и в блоке много новых ребят, которые оспаривают его на своей собственной территории (JVM). Однако Java 8 прибыла и принесла несколько интересных функций. Эти интересные возможности позволили написать новые удивительные фреймворки, такие как веб-фреймворк Spark или Javaslang .

В этом посте мы рассмотрим Javaslang, который переносит функциональное программирование на Java.

Функциональное программирование: для чего это нужно?

Кажется, что сегодня все крутые разработчики хотят заниматься функциональным программированием. Так как раньше они хотели использовать объектно-ориентированное программирование. Лично я считаю, что функциональное программирование отлично подходит для решения определенного набора проблем, в то время как другие парадигмы лучше в других случаях.

Функциональное программирование прекрасно, когда:

  • Вы можете связать его с неизменяемостью: чистая функция не имеет побочных эффектов, и ее легче рассуждать. Чистые функции означают неизменность, что значительно упрощает тестирование и отладку. Однако не все решения красиво представлены с неизменяемостью. Иногда у вас просто есть огромный кусок данных, который он разделяет между несколькими пользователями, и вы хотите изменить его на месте. В этом случае изменчивость — это путь.
  • у вас есть код, который зависит от входных данных, а не от состояния: если что-то зависит от состояния, а не от ввода, это звучит больше как метод, чем функция для меня. Функциональный код в идеале должен четко указывать, какую информацию использует (поэтому он должен использовать только параметры). Это также означает более общие и многократно используемые функции.
  • у вас есть независимая логика, которая не сильно связана: функциональный код великолепен, когда он организован в небольшие, универсальные и многократно используемые функции
  • у вас есть потоки данных, которые вы хотите преобразовать: на мой взгляд, это самое простое место, где вы можете увидеть ценности функционального программирования. Действительно, потоки получили большое внимание в Java 8.

Обсудить библиотеку

Как вы можете прочитать на javaslang.com :

Java 8 представила наши программы, но «Очевидно, что API JDK не помогут вам написать краткую функциональную логику (…)»блог jOOQ ™

Javaslang ™ — недостающая часть и лучшее решение для написания всеобъемлющих функциональных программ на Java 8+.

Это именно то, что я вижу в Javaslang: Java 8 предоставила нам возможности для создания более лаконичного и компонованного кода. Но это не сделал последний шаг. Это открыло пространство, и Javaslang прибыл, чтобы заполнить это.

Javaslang предлагает множество возможностей:

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

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

Хорошо, но на практике, как мы можем использовать этот материал?

Очевидно, что показ примера для каждой функции Javaslang выходит далеко за рамки этого поста. Давайте просто посмотрим, как мы могли бы использовать некоторые из них, и, в частности, давайте сосредоточимся на том, что такое функциональное программирование: манипулирование функциями.

Учитывая, что я одержим манипуляциями с Java-кодом, мы увидим, как мы можем использовать Javaslang для проверки абстрактного синтаксического дерева (AST) некоторого Java-кода. AST можно легко получить с помощью любимого JavaParser .

Если вы используете gradle, ваш файл build.gradle может выглядеть так:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
apply plugin: 'java'
apply plugin: 'idea'
  
sourceCompatibility = 1.8
  
repositories {
    mavenCentral()
}
  
dependencies {
    compile "com.javaslang:javaslang:2.0.0-beta"
    compile "com.github.javaparser:javaparser-core:2.3.0"
    testCompile "junit:junit:4.12"
}

Мы собираемся реализовать очень простые запросы. На вопросы мы можем ответить, просто взглянув на AST без решения символов. Если вы хотите поиграть с Java AST и решить символы, вы можете взглянуть на мой проект: java-symbol-solver .

Например:

  • найти классы с методом с заданным именем
  • найти классы с методом с заданным количеством параметров
  • найти классы с заданным именем
  • объединение предыдущих запросов

Давайте начнем с функции, которая имеет CompilationUnit и имя метода возвращает список TypeDeclarations, определяющих метод с этим именем. Для людей, которые никогда не использовали JavaParser: CompilationUnit представляет собой полный файл Java, возможно, содержащий несколько TypeDeclarations. TypeDeclaration может быть классом, интерфейсом, перечислением или объявлением аннотации.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.body.MethodDeclaration;
import com.github.javaparser.ast.body.TypeDeclaration;
import javaslang.Function1;
import javaslang.Function2;
import javaslang.collection.List;
  
...
  
    /**
     * Helper method
     */
    public static boolean hasMethodNamed(TypeDeclaration typeDeclaration, String methodName) {
        return List.ofAll(typeDeclaration.getMembers())
                .map(Match.whenType(MethodDeclaration.class)
                        .then((t)-> Option.of(t.getName())).otherwise(() -> Option.none()))
                .map((n)->n.isDefined() && n.get().equals(methodName))
                .reduce((a, b)->a || b);
    }
  
    public static List<TypeDeclaration> getTypesWithThisMethod(CompilationUnit cu, String methodName) {
        return List.ofAll(cu.getTypes()).filter((t) -> hasMethodNamed(t, methodName));
    }

getTypesWithThisMethod очень прост: мы берем все типы в CompilationUnit ( cu.getTypes () ) и фильтруем их, выбирая только те типы, у которых есть метод с таким именем. Настоящая работа сделана в hasMethodNamed .

В hasMethodNamed мы начинаем с создания javaslang.collection.List из нашего java.util.List ( List.ofAll (typeDeclaration.getMembers () ) . Затем мы считаем, что нас интересуют только MethodDeclarations : нас не интересует поле объявления или другие вещи, содержащиеся в объявлении типа. Таким образом, мы сопоставляем каждое объявление метода либо с Option.of (true), если имя метода совпадает с выбранным methodName, в противном случае мы сопоставляем его с Option.of (false) . не MethodDeclaration сопоставляется с Option.none () .

Так, например, если мы ищем имя метода «foo» в классе, который имеет три поля, за которыми следуют методы «bar», «foo» и «baz», мы получим список:

Option.none (), Option.none (), Option.none (), Option.of (false) , Option.of (true) , Option.of (false) .

Следующим шагом является сопоставление Option.none () и Option.of (false) со значением false, а Option.of (true) со значением true . Обратите внимание, что мы могли бы получить это немедленно, вместо того, чтобы объединить операцию двух карт. Однако я предпочитаю делать все по шагам. Как только мы получим список значений true и false, нам нужно извлечь из него одно единственное значение, которое должно быть истинным, если список содержит хотя бы одно значение true, и false в противном случае. Получение одного значения из списка называется операцией сокращения. Существуют различные варианты такого рода операций: я позволю вам разобраться в деталях 🙂

Мы могли бы переписать последний метод так:

01
02
03
04
05
06
07
08
09
10
11
12
public List<TypeDeclaration> getTypesWithThisMethod(CompilationUnit cu, String methodName) {
    Function2<TypeDeclaration, String, Boolean> originalFunction =
            AstExplorer::hasMethodNamed;
    Function2<String, TypeDeclaration, Boolean> originalFunctionReversed =
            originalFunction.reversed();
    Function1<String, Function1<TypeDeclaration, Boolean>> originalFunctionReversedAndCurried =
            originalFunction.reversed().curried();
    Function1<TypeDeclaration, Boolean> originalFunctionReversedAndCurriedAndAppliedToMethodName =
            originalFunction.reversed().curried().apply(methodName);
    return List.ofAll(cu.getTypes()).filter(asPredicate(
            originalFunctionReversedAndCurriedAndAppliedToMethodName));
}

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

Сначала короткое примечание: класс Function1 указывает на функцию, принимающую один параметр. Первый универсальный параметр — это тип параметра, принятого функцией, а второй — тип значения, возвращаемого функцией. Функция 2 принимает вместо 2 параметров. Вы можете понять, как это происходит 🙂

Мы:

  • обратный порядок, в котором параметры могут быть переданы функции
  • мы создаем частично примененную функцию: это функция, в которой первый параметр является «фиксированным»

Поэтому мы создаем нашу originalFunctionReversedAndCurriedAndAppliedToMethodName, просто манипулируя исходной функцией hasMethodNamed . Исходная функция принимала 2 параметра: TypeDeclaration и имя метода. Наша разработанная функция принимает только TypeDeclaration. Это все еще возвращает логическое значение.

Затем мы просто преобразуем нашу функцию в предикат с помощью этой крошечной функции, которую мы можем использовать снова и снова:

1
2
3
private static <T> Predicate<T> asPredicate(Function1<T, Boolean> function) {
    return v -> function.apply(v);
}

Вот как мы можем сделать его более общим:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
/**
 * Get all the types in a CompilationUnit which satisfies the given condition
 */
public List<TypeDeclaration> getTypes(CompilationUnit cu, Function1<TypeDeclaration, Boolean> condition) {
    return List.ofAll(cu.getTypes()).filter(asPredicate(condition));
}
 
/**
 * It returns a function which tells has if a given TypeDeclaration has a method with a given name.
 */
public Function1<TypeDeclaration, Boolean> hasMethodWithName(String methodName) {
    Function2<TypeDeclaration, String, Boolean> originalFunction = AstExplorer::hasMethodNamed;
    return originalFunction.reversed().curried().apply(methodName);
}
 
/**
 * We could combine previous function to get this one and solve our original question.
 */
public List<TypeDeclaration> getTypesWithThisMethod(CompilationUnit cu, String methodName) {
    return getTypes(cu, hasMethodWithName(methodName));
}

Хорошо, теперь мы можем обобщить hasMethodWithName:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
/**
 * This function returns true if the TypeDeclaration has at
 * least one method satisfying the given condition.
 */
public static boolean hasAtLeastOneMethodThat(
        TypeDeclaration typeDeclaration,
        Function1<MethodDeclaration, Boolean> condition) {
    return List.ofAll(typeDeclaration.getMembers())
            .map(Match.whenType(MethodDeclaration.class)
                    .then(m -> condition.apply(m)).otherwise(false))
            .reduce((a, b)->a || b);
}
 
/**
 * We refactor this function to reuse hasAtLeastOneMethodThat
 */
public static boolean hasMethodWithName(TypeDeclaration typeDeclaration, String methodName) {
    return hasAtLeastOneMethodThat(typeDeclaration, m -> m.getName().equals(methodName));
}

После некоторого рефакторинга мы получаем этот код:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package me.tomassetti.javaast;
 
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.body.MethodDeclaration;
import com.github.javaparser.ast.body.TypeDeclaration;
import javaslang.Function1;
import javaslang.Function2;
import javaslang.collection.List;
import javaslang.control.Match;
 
import java.util.function.Predicate;
 
public class AstExplorer {
 
    public static boolean hasAtLeastOneMethodThat(
            TypeDeclaration typeDeclaration,
            Function1<MethodDeclaration, Boolean> condition) {
        return hasAtLeastOneMethodThat(condition).apply(typeDeclaration);
    }
 
    public static Function1<TypeDeclaration, Boolean> hasAtLeastOneMethodThat(
            Function1<MethodDeclaration, Boolean> condition) {
        return t -> List.ofAll(t.getMembers())
                .map(Match.whenType(MethodDeclaration.class)
                        .then(m -> condition.apply(m)).otherwise(false))
                .reduce((a, b)-> a || b);
    }
 
    public static boolean hasMethodNamed(TypeDeclaration typeDeclaration, String methodName) {
        return hasAtLeastOneMethodThat(typeDeclaration, m -> m.getName().equals(methodName));
    }
 
    private static <T> Predicate<T> asPredicate(Function1<T, Boolean> function) {
        return v -> function.apply(v);
    }
     
    public static List<TypeDeclaration> typesThat(
            CompilationUnit cu, Function1<TypeDeclaration,
            Boolean> condition) {
        return List.ofAll(cu.getTypes()).filter(asPredicate(condition));
    }
 
    public static Function1<TypeDeclaration, Boolean> methodHasName(String methodName) {
        Function2<TypeDeclaration, String, Boolean> originalFunction = AstExplorer::hasMethodNamed;
        return originalFunction.reversed().curried().apply(methodName);
    }
 
    public static List<TypeDeclaration> typesWithThisMethod(CompilationUnit cu, String methodName) {
        return typesThat(cu, methodHasName(methodName));
    }
     
}

Теперь посмотрим, как это можно использовать:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
package me.tomassetti.javaast;
 
import com.github.javaparser.JavaParser;
import com.github.javaparser.ParseException;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.body.MethodDeclaration;
import com.github.javaparser.ast.body.TypeDeclaration;
import javaslang.Function1;
import javaslang.collection.List;
import org.junit.Test;
 
import java.io.InputStream;
import static me.tomassetti.javaast.AstExplorer.*;
import static org.junit.Assert.*;
 
public class AstExplorerTest {
 
    @Test
    public void typesNamedA() throws ParseException {
        InputStream is = AstExplorerTest.class.getResourceAsStream("/SomeJavaFile.java");
        CompilationUnit cu = JavaParser.parse(is);
        Function1<MethodDeclaration, Boolean> isNamedBar = m -> m.getName().equals("bar");
        List<TypeDeclaration> res = typesThat(cu, hasAtLeastOneMethodThat(isNamedBar));
        assertEquals(2, res.length());
        assertEquals("A", res.get(0).getName());
        assertEquals("B", res.get(1).getName());
    }
 
    @Test
    public void typesHavingAMethodNamedBar() throws ParseException {
        InputStream is = AstExplorerTest.class.getResourceAsStream("/SomeJavaFile.java");
        CompilationUnit cu = JavaParser.parse(is);
        Function1<MethodDeclaration, Boolean> isNamedBar = m -> m.getName().equals("bar");
        List<TypeDeclaration> res = typesThat(cu, hasAtLeastOneMethodThat(isNamedBar));
        assertEquals(2, res.length());
        assertEquals("A", res.get(0).getName());
        assertEquals("B", res.get(1).getName());
    }
 
    @Test
    public void typesHavingAMethodNamedBarWhichTakesZeroParams() throws ParseException {
        InputStream is = AstExplorerTest.class.getResourceAsStream("/SomeJavaFile.java");
        CompilationUnit cu = JavaParser.parse(is);
        Function1<MethodDeclaration, Boolean> hasZeroParam = m -> m.getParameters().size() == 0;
        Function1<MethodDeclaration, Boolean> isNamedBar = m -> m.getName().equals("bar");
        List<TypeDeclaration> res = typesThat(cu, hasAtLeastOneMethodThat(m ->
                hasZeroParam.apply(m) && isNamedBar.apply(m)));
        assertEquals(1, res.length());
        assertEquals("A", res.get(0).getName());
    }
 
    @Test
    public void typesHavingAMethodNamedBarWhichTakesOneParam() throws ParseException {
        InputStream is = AstExplorerTest.class.getResourceAsStream("/SomeJavaFile.java");
        CompilationUnit cu = JavaParser.parse(is);
        Function1<MethodDeclaration, Boolean> hasOneParam = m -> m.getParameters().size() == 1;
        Function1<MethodDeclaration, Boolean> isNamedBar = m -> m.getName().equals("bar");
        List<TypeDeclaration> res = typesThat(cu, hasAtLeastOneMethodThat(m ->
                hasOneParam.apply(m) && isNamedBar.apply(m)));
        assertEquals(1, res.length());
        assertEquals("B", res.get(0).getName());
    }
 
}

Исходный файл, который мы использовали в этих тестах:

1
2
3
4
5
6
7
8
9
class A {
    void foo() { }
    void bar() { }
}
 
class B {
    void bar(int x) { }
    void baz() { }
}

Это, конечно, очень, очень, очень ограниченное введение в возможности Javaslang . То, что я считаю важным для новичка в функциональном программировании, — это стремление писать очень маленькие функции, которые можно составлять и манипулировать для получения очень гибкого и мощного кода. Функциональное программирование может показаться неясным, когда мы начнем его использовать, но если вы посмотрите на написанные нами тесты, я думаю, что они довольно четкие и описательные.

Функциональное программирование: все ли оправдано?

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

Выводы

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