Расширение Функциональных Интерфейсов Java

7940a8f21a5b83d487d94a13aed85dab

За годы прошедшие с появления в Java8 функциональных интерфейсов у меня набралась коллекция полезных решений и шаблонов, которые я переносил из проекта в проект, и которые в стандартной версии так и не были осуществлены. Недавно я решил собрать все вместе в небольшом проекте с открытым кодом. В первом релизе проекта расширения охватывают следующие аспекты:

  • Расширение набора интерфейсов

  • Карринг и частичное применение

  • Перехват

  • Обработка исключений

  • Мультиметоды

Проект на GitHUB

multifunctions

Maven

multifunction

Библиотека написана на Java 21 и не содержит зависимостей.


    com.aegisql.multifunction
    multifunction
    1.1

Расширение набора интерфейсов

В Java стандартно поддерживаются функциональные интерфейсы с не более чем двумя аргументами. Это такие интерфейсы как BiFunction, BiConsumer, BinaryOperator и некоторые другие. Практически полезно иметь возможность неограниченно увеличивать количество аргументов по мере необходимости, а так же избегать ненужных повторов. Кроме того, все расширения интерфейсов реализованы единообразно и имеют взаимосвязь друг с другом.

Об именах интерфейсов

Два основных типа интерфейсов — функции и консьюмеры. Функция возвращает результат. Консьюмер — нет (тип void).
Соответственно, все интерфейсы для функций называются Function1, Function2, … FunctionN по количеству своих аргументов. Аналогично для консьюмеров: Consumer1, Consumer2ConsumerN.
Функция с нулем аргументов представлена интерфейсом SupplierExt.
Консьюмер с нулем аргументов — интерфейсом RunnableExt.
Также, определены интерфейсы Predicate[N] и ToInt[N]Function, как аналоги Predicate и ToIntFunction из стандартной коллекции интерфейсов. В настоящее время они имеют вспомогательную роль и не получили, должного развития. Это может измениться в будущем.
В первом релизе максимальное количество аргументов равно десяти. Это интерфейсы Function10 и Consumer10. Число аргументов может быть увеличено до любого практически необходимого значения с помощью утилит — генераторов кода.

Инстациация интерфейсов.

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

Function2 min2 = Math::min;
Function3 min3 = (x,y,z)->Math.min(x,Math.min(y,z));

Даже на примере короткой трехпараметрической функции видно, что сложность определения быстро увеличивается. Для данного примера, к сожалению, альтернативы нет, поскольку модуль Math содержит разные версии функции min() для разных типов, и компилятору требуется явная подсказка. Если же неоднозначность отсутствует, в каждом интерфейсе существует статический метод of(…), позволяющий существенно упростить ввод.

public static String hello(String greet, String user) {
    return greet+", "+user+"!";
}
…
var greet = Function2.of(Greetings::hello);

Так для любого количества аргументов.

Карринг и частичное применение

Java остается в основном объектно-ориентированным языком. Добавление в Java функций первого класса, с одной стороны, невероятно расширила возможности и выразительные средства языка, с другой, разочаровала многих поклонников языков Функционального Программирования.

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

Карринг дает следующие преимущества:

  • Более простой интерфейс с меньшим количеством параметров.

  • Фиксация некоторого количества аргументов позволяет получить функцию зависящую только от изменчивых аргументов, что снижает вероятность ее неправильного применения.

  • Частичное применение позволяет из одной функции получить целое семейство функций с более четко определенным назначением.

  • Ленивое выполнение. Аргументы могут быть частично применены там и тогда где они доступны. Окончательное же связывание всех аргументов происходит только в момент выполнения конечной функции с оставшимися аргументами.

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

Каждый интерфейс N-го порядка имеет N пар методов applyArg[i] для функции и acceptArg[i] для консьюмеров. Пар, поскольку один из методов принимает значение, а второй Supplier для значения. Метод возвращает Функцию или Консьюмер порядка N-1.

Примеры:
Пусть у нас есть метод генерирующий приветствие для обращения к клиенту:

public String hello3(String greet, String pref, String user) {
    return greet+" "+pref+" "+user+"!";
}

В соответствии с полом клиента префикс может быть Mr. Или Mrs.
Метод hello3 может быть преобразован в семейство функций, каждая со своим значением префикса. Заметим, что префикс — это второй аргумент метода, значит, для его частичного применения понадобится метод applyArg2(String arg):

var greet3 = Function3.of(this::hello3);
var maleGreet = greet3.applyArg2("Mr.");
var femaleGreet = greet3.applyArg2("Mrs.");

maleGreet и femaleGreet имеют тип Function2, то есть, от трех аргументов осталось два. greet и user.

String smithGreet = maleGreet.apply("To:", "Smith"); //To: Mr Smith!
String leeGreet = femaleGreet.apply("Dear", "Lee"); //Dear Mrs. Lee!

Можно продолжить и получить функцию требующую только имя пользователя.

var toMaleGreet = maleGreet.applyArg1("To:"); //For formal letters
String smithGreet = toMaleGreet.apply("Smith");

Можно захватить сразу все параметры, получив беспараметрический SupplierExt.

SupplierExt helloWorldGreet = greetр.lazyApply("Hello”,”Brave New", "World"); 
var helloWorld = helloWorldGreet.get();//Hello Brave New World!

Анкарринг

Если функция получена путем картинга другой функции, то ее можно «распаковать», получив обратно исходную функцию.

Function3 uncurry = maleGreet.uncurry();

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

Function3 uncurry = maleGreet.uncurry();

Если анкарринг не желателен, просто оберните функцию методом of() соответствующего интерфейса. Попытка анкарринга вызовет UnsupportedOperationException.

var mrGreet = Function2.of(greet3.applyArg2("Mr."));
mrGreet.uncurry(); // throws exception

Перехват

Иногда требуется выполнить какие-то действия до (метод before или после (метод after) основной функции. Например, сделать валидацию параметров, поместить запись в лог, послать уведомление о вызове функции. Для консьюмеров контекст до и после полностью определяется аргументами. Поэтому, в качестве перехватчиков используются дополнительные консьюмеры той же размеренности и того же типа аргументов, что и основной консьюмер.
Для функций before вызова контекст так же определяется только аргументами, но после вызова финки в контексте возникает новый элемент — результат, который должен учитываться в постфиксном консьюмере метода after. Его размеренность на единицу больше, и включает тип результата. Так же как и в случае анкарринга перехват результата не поддерживается для крайних в имплементации интерфейсах.
Также, все функции поддерживают метод andThen, знакомый по стандартным интерфейсам, позволяющий трансформировать результат к другому значению или типу.

Пример:
Мы хотим посчитать и отформатировать проценты используя следующий метод:

public static double div(double x, double y) {
    return x/y;
}

Перед вызовом надо убедиться, что оба числа положительные, и что делитель не равен нулю.
После вычисления мы хотим распечатать аргументы и результат.
Результат должен быть преобразован в строку с точностью 0.1, и оканчивающуюся знаком процента

var div = Function2.of(RandomTest::div);
var intercepted = div.before((x, y) -> {
            if (x < 0 || y < 0) throw new RuntimeException("arguments must be positive");
            if (y == 0) throw new RuntimeException("y cannot be zero");
        })
        .after((x, y, res) -> {
            System.out.println(x + "/" + y + "=" + res);
Ss        })
        .andThen(res->"%.1f%%".formatted(100*res));

intercepted.apply(1d,-10d); //This will throw an exception

String res = intercepted.apply(11.0, 37.0); 
//29.7%

Обработка исключений

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

Вопрос на который мы должны ответить, когда сталкиваемся с необходимостью обработки исключений, какой эффект мы хотим произвести? Вариантов, на самом деле, не так много. Мы можем:

  • Проигнорировать исключение и продолжить выполнение, как будто ничего не произошло

  • Обернуть перехваченное исключение в RuntimeException и бросить его.

  • Вернуть дефолтное значение, включая null.

  • Вернуть Optional.empty()

  • Произвести какие то дополнительные действия, после чего выполнить что-то из выше перечисленного.

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

@FunctionalInterface
public interface Function3  {
   @FunctionalInterface
    interface Throwing{ R apply(A1 a1,A2 a2,A3 a3) throws Exception; }
…

Методы throwing

Семейство статических методов throwing(…) работаот аналочично методу of(…),
Но в отличие от него могут принимать функции бросающие исключения, и возвращают функцию обернутую в try-catch блок. throwing можно применять и к тем функциям, которые бросают RuntimeException, если желательно его перехватить.

Следующий пример я хотел бы представить в виде законченного мини-проекта.
Допустим, мы хотим подсчитывать размер какой либо директории. Без или с заходом вглубь поддиректорий.
Для этого можно использовать стандартный метод Files.walkFilesTree(…) JavaDoc

Который определен так:

public static Path walkFileTree(Path start,
                                Set options,
                                int maxDepth,
                                FileVisitor visitor)
                         throws IOException

Выглядит мудрено.
Первое, что мы видим, это то, что метод принимает 4 параметра, возвращает Path, и кидает IOException. Начнем с очевидного — обернем метод в экземпляр Function4 использованием метода throwing.

public static final Function4, Integer, FileVisitor, Path> FILE_TREE_WALKER = Function4.throwing(Files::walkFileTree);

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

Смотрим дальше. enum FileVisitOption предлагает только одну опцию — следовать символичным линкам. Соответствующий сет может быть либо пустым (не следовать), либо содержать FOLLOW_LINKS (следовать). Отлично! Преобразуем дальше нашу функцию в две с меньшей размеренностью.

public static final Function3, Path> FOLLOW_LINKS_FILE_TREE_WALKER = FILE_TREE_WALKER.applyArg2(Set.of(FOLLOW_LINKS));
public static final Function3, Path> NO_FOLLOW_LINKS_FILE_TREE_WALKER = FILE_TREE_WALKER.applyArg2(Collections::emptySet);

Дальше. maxDepth задает глубину рекурсии обхода дерева директорий. Здравый смысл подсказывает, что в 90% случаев нас будет интересовать либо размер директории первого уровня, либо полный размер, включающий все под-директории. Прекрасно!. Избавимся еще от одной размеренности.

public static final Function2, Path> DEEP_FOLLOW_LINKS_FILE_TREE_WALKER = FOLLOW_LINKS_FILE_TREE_WALKER.applyArg2(Integer.MAX_VALUE);
public static final Function2, Path> SHALLOW_FILE_TREE_WALKER = NO_FOLLOW_LINKS_FILE_TREE_WALKER.applyArg2(1);
public static final Function2, Path> DEEP_NO_FOLLOW_LINKS_FILE_TREE_WALKER = NO_FOLLOW_LINKS_FILE_TREE_WALKER.applyArg2(Integer.MAX_VALUE);

Давайте теперь разберемся с визитором. Тут без вариантов. Придется создать визитор для подсчета размера директории. Для простоты и экономии места предложим следующий вариант.

public static class SizeCountingFileVisitor extends SimpleFileVisitor {
    private final AtomicLong counter = new AtomicLong();
    public long getCollectedSize() {return counter.get();}
    @Override
    public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
        counter.addAndGet(attrs.size());
        return CONTINUE;
    }
}

В следующем разделе мы посмотрим, а какие вообще альтернативы визиторам возможны, а пока — данный класс просто содержит атомарный счетчик, который увеличивает размер на размер очередного обнаруженного файла. Ну и, интерфейс доступа к накопленному значению.

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

String path -> long size

Преобразование строки в Path это, по сути, Function1

public static final Function1 PATH_OF = Function1.of(Path::of);

И остался последний шаг. Собираем все вместе. Например, так.

public static final SupplierExt> NEW_COUNTING_SHALLOW_FILE_TREE_WALKER = ()->{
    var visitor = new SizeCountingFileVisitor();
    var sizeCountingWalker = SHALLOW_FILE_TREE_WALKER.applyArg2(visitor);
    var walker = PATH_OF.andThen(sizeCountingWalker);
    return walker.andThen(_ -> visitor.getCollectedSize());
};

public static final SupplierExt> NEW_COUNTING_DEEP__FILE_TREE_WALKER = ()->{
    var visitor = new SizeCountingFileVisitor();
    var sizeCountingWalker = DEEP_NO_FOLLOW_LINKS_FILE_TREE_WALKER.applyArg2(visitor);
    var walker = PATH_OF.andThen(sizeCountingWalker);
    return walker.andThen(_ -> visitor.getCollectedSize());
};

Используем.

var shallowWalker = NEW_COUNTING_SHALLOW_FILE_TREE_WALKER.get();
System.out.println("Shallow Size: "+shallowWalker.apply("./src/main/java/com/aegisql/multifunction/"));

var deepWalker = NEW_COUNTING_DEEP_FILE_TREE_WALKER.get();
System.out.println("Deep Size: "+deepWalker.apply("./src/main/java/com/aegisql/multifunction/"));

Вывод:

Shallow Size: 333399
Deep Size: 351563

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

Методы optional и orElse

Другой способ работы исключениями — вернуть Optional или дефолтное значение.

Рассмотрим для примера функцию деления:

Function2 div = (x,y)->x/y;
Integer res = div.apply(10, 0);
//java.lang.ArithmeticException: / by zero

Деление на ноль вызвало исключение.

Function2> divOptional = div.optional();
Optional resOptional = divOptional.apply(10, 0);

Безопасная операция. Функция вернула Optional.empty()

Если преобразование возвращаемого типа в Optional не желательно, можно воспользоваться дефолтным значением.

Function2 divDef = div.orElse(() -> null); 

Integer resDef = divDef.apply(10, 0);
//NULL

В данном случае в качестве дефолтного значения мы выбрали null, который никак не может получиться в результате обычного деления. Использование «магических значений» должно применяться с осторожностью.

Другая версия метода orElse принимает конкретное значение, которое надо вернуть в случае исключения.

Мультиметоды

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

В данной библиотеке применяется весьма прямолинейный подход к диспетчеризации. Предполагается, что набор аргументов передаваемых функции-диспетчеру достаточен как для решения задачи диспетчеризации, так и для вызова любой из внутренних функций. Никаких ограничений на сами аргументы и их количество не накладывается. Но хотя бы один аргумент необходим.

В библиотеке представлено два сорта статических методов dispatch(…) В интерфейсах Function[N] диспетчеры возвращают Function[N], «обертывающую» логику работы диспетчера и вызываемых функций в единую функцию. Соответственно, в интерфейсах Consumer[N] возвращается Consumer[N]

Первый метод dispatch принимает Predicate[N] в качестве первого аргумента, и две Function[N]. Первая функция вызывается если предикат вернул истину. Второй — если ложь.

Второй метод dispatch принимает ToInt[N]Function в качестве первого аргумента, и массив переменной длины функций Function[N]. ToInt[N]Function должна вернуть в качестве результата индекс нужной функции в передаваемом массиве.

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

Function3 dispatch = Function3.dispatch(
        (op,_,_)->switch (op.toUpperCase()) {
            case "MIN" -> 0;
            case "MAX" -> 1;
            case "POW" -> 2;
            case "ROOT_N" -> 3;
            case "MUL" -> 4;
            case "DIV" -> 5;
            default -> throw new UnsupportedOperationException(STR."Unsupported operation: \{op}");
        },
        (_,x,y)->Math.min(x,y),
        (_,x,y)->Math.max(x,y),
        (_,x,y)->Math.pow(x,y),
        (_,x,y)->Math.pow(x,1/y),
        (_,x,y)->x*y,
        (_,x,y)->x/y
);

Double min = dispatch.apply("MIN", 10d, 15d);
//10
Double max = dispatch.apply("MAX", 10d, 15d);
//15
Double pow = dispatch.apply("POW", 2d, 3d);
//8
Double root = dispatch.apply("ROOT_N", 8d, 3d);
//2
Double mul = dispatch.apply("MUL", 2d, 3d);
//6
Double div = dispatch.apply("DIV", 2d, 3d);
//0.66666667
dispatch.apply("LOG_N",2d,1024d);
// throws "UnsupportedOperationException”               

Заключение

Надеюсь, данная статья оказалась вам в чем-то полезной. Представленный код может быть использован «как есть», свободно копироваться и распространяться и цитироваться с сохранением информации об авторстве. Автор будет рад узнать о других шаблонах применения функциональных интерфейсов, с которыми вы сталкивались в своих проектах. Возможно, они будут включены в следующие релизы.

© Habrahabr.ru