Статьи

Функциональный стиль — часть 5

Функции высшего порядка I: Композиция функций и паттерн Монады.

Что такое функция высшего порядка?

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

Теперь, когда функция принимает другую функцию в качестве аргумента, или она возвращает другую функцию в качестве возвращаемого значения — или и то, и другое — она ​​называется функцией более высокого порядка . На самом деле мы уже видели пример в предыдущей статье, если вспомнить упражнение «Сито Эратосфена», в котором была реализована эта функция:

1
2
3
4
5
6
7
private Predicate<Integer> notInNonPrimesUpTo(int limit) {
    var sieve = new HashSet<Integer>();
    for (var n = 2; n <= (limit / 2); n++)
        for (var nonPrime = (n * 2); nonPrime <= limit; nonPrime += n)
            sieve.add(nonPrime);
    return candidate -> !sieve.contains(candidate);
}

Эта функция возвращает Predicate . Предикат — это функция, которая выдает логическое значение. Это означает, что notInNonPrimesUpTo является notInNonPrimesUpTo более высокого порядка: она создает сито и выдает функцию, которая проверяет, находится ли число внутри сита или нет.

Мы видели и другие примеры. Вы помните map из третьей части? Он берет функцию и применяет ее ко всем элементам в массиве, получая другой массив. map является функцией высшего порядка. То же самое относится и к filter потому что он принимает предикат, проверяет его на каждом элементе массива и использует результат предиката, чтобы решить, следует ли сохранить элемент или отбросить его. qsort является функцией высшего порядка, потому что она берет функцию сравнения и использует ее для определения порядка любых двух элементов в массиве, не зная типов элементов. Так что предыдущая статья была полна функций высшего порядка, и вам не следует пугаться этого термина. Это не значит что-то редкое или возвышенное. Вы почти наверняка регулярно используете какие-то функции высшего порядка в своей работе. На самом деле, функции первого класса бесполезны без функций более высокого порядка, чтобы передавать их или возвращать из них.

Состав функции.

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

Скажем, у вас есть функция f, которая принимает значение x в качестве аргумента и возвращает значение y:

f (x) = y

и у вас есть другая функция g, которая принимает y в качестве аргумента и возвращает z:

г (у) = г

ясно, что вы можете применить g к выводу f следующим образом:

g (f (x)) = z

Следовательно, это означает, что существует третья функция h, которая отображает x непосредственно в z:

h (x) = z

Функциональные программисты сказали бы, что h — это композиция функций f и g. В Haskell это будет определено так:

1
h = g . f

В Haskell минимализм ценится как добродетель. В Clojure, более многословном, это можно определить так:

1
(def h (comp f g))

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

1
(defn h [arg] (g (f arg)))

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

Функциональная композиция как сантехника.

Идея составления функций вместе не нова. В 1964 году Даг Макилрой написал это в записке:

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

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

1
program1 | program2

В результате создается канал, который читает все данные, записанные в стандартный вывод program1 и передает их дословно в program2 через стандартный ввод. Это означает, что вы можете объединять программы, например, строительные блоки, для выполнения задач, которые ни одна из программ не может выполнить самостоятельно. Например, если бы я хотел найти топ-3 крупнейших программ Java в каталоге по строкам кода, я мог бы сделать это:

1
2
3
4
wc -l *.java | grep \.java | sort -nr | head -n 3
        82 Book.java
        43 Isbn.java
        38 Genre.java

Макилрой выразился так:

Это философия Unix: пишите программы, которые делают одно и делают это хорошо. Напишите программы для совместной работы.

Замените «программы» на «функции», и вы получите принцип компоновки.

Ощущение казни.

Итак, я думаю, что ценность написания функций, которые «делают одно и делают это хорошо», в значительной степени самоочевидна, но, возможно, пока неясно, почему это хорошая идея — писать функции для компоновки, то есть для совместной работы. Возможно, вы слышали о смущении . Connascence — это способ описания вещей, которые связаны друг с другом различными способами. Есть много различных типов коннасценции, в том числе:

  • Connascence of name — если имя предмета изменено, другие вещи должны быть переименованы, чтобы соответствовать, или программа сломается. Обычно вызовы функций работают по совпадению имени. Современные IDE рефакторинга могут помочь вам при переименовании, автоматически обновляя все другие имена, которые должны быть изменены, чтобы соответствовать.
  • Появление типа — две или более вещи должны иметь одинаковый тип. В статически типизированных языках это обычно может быть применено компилятором, но если вы работаете на динамически типизированном языке, вы должны позаботиться о том, чтобы сопоставить типы самостоятельно.
  • Смешение смысла — также часто называемое «магическими ценностями», это относится к вещам, которые должны быть установлены на определенные значения, которые имеют определенные значения и, если они будут изменены, нарушат программу.
  • Зажигание казни — вещи должны происходить в определенном порядке, другими словами, временная связь.

Это последнее, что важно для нас здесь. В программировании часто очень важно, чтобы все было сделано в определенном порядке:

1
2
3
4
5
6
email = createEmail()
email.sender("walter@lebowski.com")
email.addRecipient("thedude@lebowski.com")
email.subject("Proposal")
mailer.send(email)
email.body("Let's go bowling")

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

Но мы можем спроектировать вещи так, чтобы стало невозможно делать вещи не по порядку:

1
2
3
4
5
6
mailer.send(emailBuilder()
        .sender("walter@example.com")
        .addRecipient("thedude@example.com")
        .subject("Proposal")
        .body("Let's go bowling")
        .build())

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

Ужасная Монада.

Когда я впервые задумал эту серию статей, я думал, что вообще не буду упоминать монады, но по мере ее развития я понял, что любое обсуждение функционального стиля было бы неполным без них. Более того, монады иногда появляются, не объявляя о себе. Я долго пытался понять Монаду, и объяснения, которые я нашел, были совершенно бесполезны, и я верю, что именно поэтому они приобрели репутацию трудного для понимания. Я попытаюсь объяснить это здесь с точки зрения кода, который, я надеюсь, достаточно четко изложит концепцию. Как всегда, у меня есть пример, чтобы проиллюстрировать это с помощью; это небольшой Java-проект, который я использую для опробования идей, который реализует простой API веб-сервиса, включающий в себя набор конечных точек, которые претендуют на обслуживание библиотеки. С его помощью вы можете искать книги, просматривать их детали, брать и возвращать их и т. Д. Существует конечная точка для получения книги по номеру ISBN, и ее реализация выглядит следующим образом:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
public LibraryResponse findBookByIsbn(Request request) {
    try {
        Isbn isbn = isbnFromPath(request);
        Book book = findBookByIsbn(isbn);
        SingleBookResult result = new SingleBookResult(book);
        String responseBody = new Gson().toJson(result);
        return new LibraryResponse(200, "application/json", responseBody);
    } catch (IllegalArgumentException e) {
        return new LibraryResponse(400, "text/plain", "ISBN is not valid");
    } catch (Exception e) {
        LOG.error(e.getMessage(), e);
        return new LibraryResponse(500, "text/plain", "Problem at our end, sorry!");
    }
}

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

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

Теперь нам не нужно вдаваться в детали используемого здесь веб-фреймворка (это spark-java ); Достаточно сказать, что все веб-фреймворки можно настроить так, чтобы они перехватывали необработанные исключения и возвращали предварительно настроенный HTTP-ответ, когда они происходят. Разные ответы могут быть сопоставлены с разными классами исключений: было бы целесообразно возвращать ответ HTTP 500 при возникновении Exception верхнего уровня, поэтому мы можем удалить этот блок catch из метода findBookByIsbn .

С другой стороны, ответ 400 «ISBN не действителен» происходит из-за неверного ввода от клиента и является очень важной частью указанного поведения API. Метод isbnFromPath IllegalArgumentException когда значение параметра от клиента не соответствует нужному формату для номера ISBN. Это то, что я имел в виду под замаскированным GOTO; это скрывает логику, потому что не сразу очевидно, откуда исходит исключение.

Есть нечто большее, что, кажется, полностью отсутствует там. Что происходит, когда findBookByIsbn не находит книгу? Это должно привести к ответу HTTP 404 и, при использовании, так оно и есть, так где же это произошло? Исследуя findBookByIsbn мы видим ответ:

1
2
3
Book findBookByIsbn(Isbn isbn) {
    return bookRepository.retrieve(isbn).orElseThrow(() -> Spark.halt(NOT_FOUND_404, BOOK_NOT_FOUND));
}

Это делает вещи еще хуже! Здесь мы используем фреймворк, с помощью которого исключение кодирует ответ HTTP 404 внутри него. Это важный поток управления, который полностью скрыт в реализации конечной точки.

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
public LibraryResponse findBookByIsbn(Request request) {
    Isbn isbn = isbnFromPath(request);
    if (isbn.valid()) {
        Optional<Book> book = findBookByIsbn(isbn);
        if (book.isPresent()) {
            SingleBookResult result = new SingleBookResult(book.get());
            String responseBody = new Gson().toJson(result);
            return new LibraryResponse(200, "application/json", responseBody);
        } else {
            return new LibraryResponse(404, "text/plain", "Book not found");
        }
    } else {
        return new LibraryResponse(400, "text/plain", "ISBN is not valid");
    }
}

По крайней мере, все различные пути выполнения теперь присутствуют в методе. Этот код тоже вряд ли findBookByIsbn , хотя там есть лучшее решение, на которое намекает метод findBookByIsbn который был изменен, чтобы теперь возвращать Optional<Book> . Этот Optional тип говорит нам о чем-то: он говорит, что может возвращать или не возвращать книгу, и что мы должны обрабатывать обе ситуации, хотя Optional можно использовать гораздо более аккуратно, чем он есть. Нам нужен способ сделать так же явным, что findBookByIsbn вернет либо действительный номер ISBN, либо какую-то недопустимую ошибку запроса.

Может быть, это действительно, а может и нет.

В Haskell есть тип Either который позволяет вам делать именно это, и он часто используется для обработки ошибок. Either значения могут быть Left или Right и программист должен иметь дело с обоими. Обычно Left конструктор используется для указания ошибки, а Right конструктор — для переноса ошибочного значения. Лично я не фанат использования «левого» и «правого» таким образом: эти слова имеют значение только для меня с точки зрения пространственной ориентации. В любом случае, у Java есть своя стереотипная конструкция для такого рода вещей, созданная классами Stream и Optional . Мы могли бы создать тип MaybeValid для MaybeValid значений, которые могут быть действительными или нет, и, разработав его так, чтобы он напоминал встроенные типы, мы могли бы вызвать наименьшее удивление:

1
2
3
4
5
6
7
8
interface MaybeValid<T> {
 
    <U> MaybeValid<U> map(Function<T, U> mapping);
 
    <U> MaybeValid<U> flatMap(Function<T, MaybeValid<U>> mapping);
 
    T ifInvalid(Function<RequestError, T> defaultValueProvider);
}

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Valid<T> implements MaybeValid<T> {
 
    private final T value;
 
    public Valid(T value) {
        this.value = value;
    }
 
    @Override
    public <U> MaybeValid<U> map(Function<T, U> mapping) {
        return new Valid<>(mapping.apply(value));
    }
 
    @Override
    public <U> MaybeValid<U> flatMap(Function<T, MaybeValid<U>> mapping) {
        return mapping.apply(value);
    }
 
    @Override
    public T ifInvalid(Function<RequestError, T> unused) {
        return value;
    }
}

Ключевые части здесь:

  • ifInvalid возвращает упакованное значение, а не выполняет предоставленную функцию.
  • map применяет обернутое значение к функции отображения и возвращает новый экземпляр MaybeValid оборачивающий сопоставленное значение.
  • flatMap применяет функцию отображения и просто возвращает ее результат, который уже обернут в экземпляр MaybeValid .
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Invalid<T> implements MaybeValid<T> {
 
    private final RequestError error;
 
    public Invalid(RequestError error) {
        this.error = error;
    }
 
    @Override
    public <U> MaybeValid<U> map(Function<T, U> unused) {
        return new Invalid<>(error);
    }
 
    @Override
    public <U> MaybeValid<U> flatMap(Function<T, MaybeValid<U>> unused) {
        return new Invalid<>(error);
    }
 
    @Override
    public T ifInvalid(Function<RequestError, T> defaultValueProvider) {
        return defaultValueProvider.apply(error);
    }
}

Принципиальные различия:

  • Методы map и flatMap не выполняют функции отображения; они просто возвращают другой экземпляр InvalidRequest . Причина, по которой им нужно создать новый экземпляр, заключается в том, что упакованный тип может измениться (с T на U ).
  • ifInvalid метод ifInvalid использует функцию defaultValueProvider для предоставления возвращаемого значения.
  • Поставщику значений по умолчанию предоставляется ошибка запроса в качестве аргумента на случай, если он понадобится для возврата соответствующего результата.

Все это означает, что нам нужно обернуть метод isbnFromPath для возврата экземпляра MaybeValid :

1
2
3
4
5
6
MaybeValid<Isbn> maybeValidIsbn(Request request) {
    Isbn isbn = isbnFromPath(request);
    return isbn.valid()
            ? new Valid<>(isbn)
            : new Invalid<>(new RequestError(400, "ISBN is not valid"));
}

И мы должны дать аналогичное обращение, чтобы findBookByIsbn :

1
2
3
4
5
MaybeValid<Book> maybeValidBook(Isbn isbn) {
    return findBookByIsbn(isbn)
            .map(book -> new Valid<>(book))
            .orElseGet(() -> new Invalid<>(new RequestError(404, "Book not found")));
}

Обратите внимание, что RequestError не является исключением; однако он содержит код состояния HTTP, поэтому этот код должен находиться в компоненте приложения, который имеет дело с запросами и ответами HTTP. Было бы неуместно жить в другом месте: например, в классе обслуживания.

Теперь мы можем переписать конечную точку следующим образом:

1
2
3
4
5
6
7
8
public LibraryResponse findBookByIsbn(Request request) {
    return maybeValidIsbn(request)
        .flatMap(isbn -> maybeValidBook(isbn))
        .map(book -> new SingleBookResult(book))
        .map(result -> new Gson().toJson(result))
        .map(json -> new LibraryResponse(200, "application/json", json))
        .ifInvalid(error -> new LibraryResponse(error.httpStatus(), "text/plain", error.body()));
}

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

Так что насчет монады …?

Я упомянул ужасное слово «монада» ранее, и вы, наверное, догадались, что MaybeValid — одно из них, иначе я бы не поднял его. Так что же такое монада? Сначала нам нужно прояснить одну вещь, потому что вы, возможно, слышали слово в контексте «монадической функции» — это совершенно другое использование. Это означает функцию с одним аргументом (функция с двумя аргументами является двоичной, а одна с тремя аргументами — триадной и т. Д.); это использование возникло в APL и не имеет ничего общего с тем, о чем мы здесь говорим. Монада, о которой мы говорим, — это шаблон дизайна.

Несомненно, вы уже знакомы с шаблонами проектирования. Те, что вы уже знаете, такие как Стратегия, Команда, Посетитель и т. Д., Являются объектно-ориентированными шаблонами проектирования. Monad — это функциональный шаблон дизайна. Шаблон Monad определяет, что означает объединять операции, позволяя программисту создавать конвейеры, которые обрабатывают данные в несколько этапов, как мы это делали выше:

  1. Получить номер ISBN из запроса (может быть недействительным, то есть неправильный формат).
  2. Найдите книгу по номеру ISBN (может быть недействительным, т.е. не найден).
  3. Создать SingleBookResult DTO из найденной книги.
  4. Сопоставьте DTO со строкой JSON.
  5. Создайте LibraryResponse со статусом 200, содержащий JSON.

Каждый шаг может быть «украшен» дополнительными правилами обработки, предоставленными монадой. В нашем случае дополнительными правилами являются:

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

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

Формальное определение.

Более формально, образец монады обычно определяется как совокупность следующих трех компонентов, которые вместе известны как тройка Клейси:

  • Конструктор типа, который отображает каждый возможный тип в соответствующий монадический тип. Эта формулировка не имеет большого смысла в Java. Чтобы понять это, подумайте об общих типах, например: MaybeValid<Isbn>MaybeValid<Isbn> .
  • Единичная функция, которая упаковывает значение в базовый тип с экземпляром соответствующего монадического типа, например: new Valid<Isbn>(isbn) .
  • Операция привязки, которая принимает функцию и применяет ее к базовому типу. Функция возвращает новый монадический тип, который становится возвращаемым значением операции привязки, например: map(book -> new SingleBookResult(book)) который MaybeValid<SingleBookResult> .

Если у вас есть эти три компонента, у вас есть монада.

Я слышал, что монады все о побочных эффектах.

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

На мой взгляд, Monad переносит типизированное значение (любого типа) и поддерживает некоторое дополнительное состояние отдельно от переносимого значения. Мы видели два примера здесь. В случае Optional монады дополнительным состоянием является наличие или отсутствие значения. В случае монады MaybeValid , является ли значение допустимым, плюс ошибка проверки в случае, если это не так. Обратите внимание, что здесь есть два типа: монадический тип (например, Optional ) и упакованный тип.

Вы можете снабдить Monad функцией, которая работает с перенесенным значением. Независимо от того, какой тип имеет переносимое значение, аргумент функции должен соответствовать ему. Монада передаст свое упакованное значение в функцию и выдаст новую монаду того же монадического типа, инкапсулирующую значение, возвращаемое функцией. Это называется «обязательной операцией». Обернутый тип новой монады может отличаться, и это нормально. Например, если у вас есть Optional перенос Date , вы можете связать функцию, которая отображает Date в String и результатом будет Optional перенос String . Если есть некоторая функциональность, связанная с дополнительным состоянием монады, монада обрабатывает ее как часть операции привязки. Например, когда вы передаете функцию в пустой Optional , функция не будет выполнена; результат — другой пустой Optional . Таким образом, вы можете вызывать цепочку составных функций в последовательности, переходя от типа к типу, все в контексте монады.

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

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

В следующий раз.

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

Опубликовано на Java Code Geeks с разрешения Ричарда Уайлда, партнера нашей программы JCG . Смотреть оригинальную статью здесь: Функциональный стиль — часть 5

Мнения, высказанные участниками Java Code Geeks, являются их собственными.