功能风格–第5部分

高阶函数I:函数组成和Monad模式。

什么是高阶函数?

在上一篇文章中,我们看到了一些作为一等公民的函数的示例以及它们可以用于的各种用途。 回顾一下,当函数本身是一个值时,它就是一等公民,并且可以像其他任何类型的值一样在程序中传递。

现在,当一个函数接受另一个函数作为其参数,或者当它产生另一个函数作为其返回值(或两者兼有)时,该函数被称为高阶函数 。 实际上,如果您回想起Eratosthenes练习的筛网,它实际上具有以下功能,我们实际上已经在其中看到了一个示例:

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是一个高阶函数:它将构建筛子并产生一个用于测试数字是否在筛子中的函数。

我们也看到了其他示例。 您还记得第三部分的map吗? 它接受一个函数并将其应用于数组中的所有元素,从而产生另一个数组。 map是高阶函数。 filter也是这样,因为它需要一个谓词,在数组的每个元素上对其进行测试,然后使用谓词的结果来决定是保留还是丢弃该元素。 qsort也是一个高阶函数,因为它使用比较器函数并使用它来确定数组中任何两个元素的顺序,而无需知道元素的类型。 因此,上一篇文章充斥着高阶函数,您不应被这个术语所吓倒。 这并不意味着任何珍贵或崇高的事物。 几乎可以肯定,您在工作中经常使用某种高阶函数。 实际上,如果没有高阶函数将它们传递进去或从中返回,则一等函数是无用的。

功能组成。

在函数式编程世界中,您会听到很多有关此的信息。 组成两个函数意味着将它们排列成一个函数的结果直接用作另一个函数的输入。 您的代码中可能充满了这样的示例,但是如果代码的结构没有突出显示这一事实,那么您可能不会总是注意到。 函数式编程人员始终会警惕以这种方式安排函数,因为这允许某些编程结构的可能性,我们将在稍后讨论。 精于功能风格的程序员通常会发现,将两个组合功能本身视为第三个功能很有用。 让我解释一下我的意思。

假设您有一个函数f,该函数将值x作为参数并返回值y:

f(x)= y

并且您还有另一个函数g,该函数将y作为其参数并返回z:

g(y)= z

显然,您可以将g应用于f的输出,如下所示:

g(f(x))= z

因此,这意味着存在第三个函数h,它将x直接映射到z:

h(x)= z

函数式程序员会说h是函数f和g的组成。 在Haskell中,定义如下:

h = g . f

在Haskell中,极简主义被视为一种美德。 在Clojure中,更为冗长,它的定义如下:

(def h (comp f g))

函数式编程的奉献者倾向于以这种方式查看函数的组成。 就我个人而言,我没有发现显式命名这样的组合函数的习惯特别有用。 特别是我看不出上述Clojure与以下代码之间有任何区别:

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

除此之外,第一个示例更为简洁。 FP的奉献者喜欢对功能组合的功能进行抒情处理,而我自己的观点则比较平淡。

功能组成为管道。

将功能组合在一起的想法并不新颖。 1964年,道格·麦克罗伊(Doug McIlroy)在一份备忘录中写道:

我们应该有一些耦合程序的方式,例如花园软管-当有必要以其他方式处理数据时,请拧入另一段。

Doug提出的想法后来在Unix中通过管道实现,这可能是使Unix Shell脚本如此强大的单个功能。 Unix管道是一个进程间通信的系统。 它们可以由系统通过系统调用直接创建和使用,但是也可以通过使用|在shell中创建它们。 符号,如下所示:

program1 | program2

效果是创建一个管道,该管道读取由program1写入标准输出的所有内容,并通过其标准输入将其逐字提供给program2 。 这意味着您可以将程序像构建块一样链接在一起,以完成程序本身无法完成的任务。 例如,如果我想按代码行在目录中找到前3大Java程序,则可以这样做:

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

麦克罗伊这样说:

这是Unix的哲学:编写可以做一件事并且做得很好的程序。 编写程序以协同工作。

用“功能”替换“程序”,您就具有了可组合性的原则。

执行死刑。

因此,我认为编写“做一件事情并做好事”的函数的价值不言而喻,但可能尚不清楚为什么编写可组合的函数(即,一起工作)是个好主意。 您可能听说过惊叹 。 连续性是描述以各种方式彼此关联的事物的一种方式。 有许多不同类型的社交活动,包括:

  • 名称的连续性-如果事物的名称被更改,则其他事物必须重命名以匹配,否则程序将中断。 通常,函数调用根据名称的存在起作用。 现代的重构IDE通过自动更新所有其他需要更改以匹配的名称,可以在重命名时为您提供帮助。
  • 类型的连续性-两个或多个事物必须具有相同的类型。 在静态类型的语言中,通常可以由编译器强制执行,但是如果您使用的是动态类型的语言,则必须小心自己匹配类型。
  • 意义的连续性-通常也称为“魔术值”,它是指必须设置为具有特定含义的特定值的内容,如果更改这些值,将破坏程序。
  • 执行的连续性-事物必须以一定顺序发生,换句话说,就是时间耦合。

这是对我们这里重要的最后一个。 在编程中按顺序完成事情通常很关键:

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

在此代码中,将创建一个电子邮件对象,然后设置发件人,收件人和主题,然后发送电子邮件。 发送电子邮件 ,它将设置电子邮件正文。 几乎可以肯定,这是错误的,可能的结果是,电子邮件将以空正文发送。 可能性较小,但不能排除的结果是,在发送后在电子邮件上设置正文可能会导致错误。 无论哪种方式,这都是不好的。

但是我们可以设计事物,以便不可能乱做事情:

mailer.send(emailBuilder()
        .sender("walter@example.com")
        .addRecipient("thedude@example.com")
        .subject("Proposal")
        .body("Let's go bowling")
        .build())

由于我们需要一个电子邮件对象传递给mailer.send ,因此我们将其创建为使得创建和设置它的唯一方法是使用构建器。 我们将删除电子邮件类上的所有setter方法,以便在构建电子邮件后无法对其进行任何修改。 因此,可以保证传递给mailer.send的对象之后不会被篡改。 上面看到的构建器模式是将命令式操作转换为可组合函数的一种非常常见的方法。 您可以使用它包装功能风格以外的东西,并使它们看起来像是。

可怕的莫纳德。

当我第一次设想这一系列文章时,我以为我根本不会提到monad,但是随着它的发展,我意识到没有它们,对功能样式的任何讨论都是不完整的。 而且,Monads有时会在没有宣布自己的情况下出现。 我花了很长时间来理解Monad,而我发现的解释却无济于事,这就是为什么他们因难以理解而享有声誉。 我将在这里尝试用代码来解释它,希望它能足够清晰地传达这个概念。 和往常一样,我有一个例子来说明这一点; 这是一个用于尝试想法的Java小项目,它实现了一个简单的Web服务API,该API包含一组假装为库提供服务的端点。 您可以使用它搜索书籍,查看它们的详细信息,借阅并退还它们,等等。这里有一个端点可以通过其ISBN号检索书籍,其实现如下所示:

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指定行为的一部分。 我们待会儿再讲。

现在,我们无需进入此处使用的Web框架的细节(它是spark-java )。 可以说所有Web框架都可以配置为捕获未处理的异常,并在发生异常时返回预配置的HTTP响应。 可以将不同的响应映射到不同的异常类:抛出顶级Exception时返回HTTP 500响应是适当的,因此我们可以从findBookByIsbn方法中删除该catch块。

另一方面,400响应“ ISBN无效”是由于来自客户端的无效输入,并且是指定API行为的很大一部分。 当客户端的参数值与ISBN编号的正确格式不匹配时, isbnFromPath方法将引发IllegalArgumentException 。 这就是我伪装的GOTO的意思; 它掩盖了逻辑,因为它不是立即知道异常来自何处。

还有更多似乎完全消失的东西。 会发生什么情况findBookByIsbn 没有找到这本书吗? 那应该导致HTTP 404响应,并且在使用中也是如此,那是在哪里发生的? 检查findBookByIsbn我们看到了答案:

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

这使情况变得更糟! 在这里,我们利用框架功能,通过该功能异常可以在其中编码HTTP 404响应。 这是重要的控制流,在端点实现中完全掩盖了这一点。

那么我们能做些什么呢? 我们可以通过为不同的结果创建特定的异常类型来改善情况,但是我们仍将异常作为控制流的一种方式。 另外,我们可以重写代码以完全不依赖异常:

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方法在其中暗示了一个更好的解决方案,该方法现已修改为返回Optional<Book>Optional类型对我们来说是一句话:它说它可能会也可能不会退回一本书,而且我们必须处理两种情况,尽管可选方法可以比现在使用的更加整洁。 我们需要的是一个方法,使之同样明确的是findBookByIsbn返回一个有效的ISBN号某种无效请求的错误。

也许是有效的,也许不是。

在Haskell中,有Either类型的Either可以让您准确地做到这一点,并且它经常用于错误处理。 Either值可以是Left还是Right和程序员必须处理这两种。 按照惯例, Left构造函数用于指示错误, Right构造函数用于包装非错误值。 就我个人而言,我不喜欢以这种方式使用“左”和“右”:这些词语对我而言仅在空间方向上有意义。 无论如何,Java对于这种事情都有自己的刻板印象构造,这是由StreamOptional类建立的。 我们可以创建一个MaybeValid类型来包装可能有效或无效的值,并且通过将其设计为类似于内置类型,我们可以引起最少的惊讶:

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函数将在无效时提供该值。 我们可以方便地分别为有效值和无效值提供单独的实现:

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实例中。
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);
    }
}

关键区别在于:

  • mapflatMap方法不执行映射功能。 他们只是返回另一个InvalidRequest实例。 他们必须创建新实例的原因是,包装类型可能会更改(从TU )。
  • 终止的ifInvalid方法使用defaultValueProvider函数提供返回值。
  • 默认值提供程序附带请求错误作为其参数,以防需要它以返回适当的结果。

所有这些意味着我们需要包装isbnFromPath方法以返回MaybeValid实例:

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进行类似处理:

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请求和响应的应用程序组件中。 使其生活在其他任何地方都是不合适的,例如在服务类中。

现在我们可以这样重写端点:

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()));
}

某些lambda可以替换为方法引用,但我保留了它们,因为它们与原始代码最相似。 还有其他可能进一步重构。 但是请注意,它现在是一系列链接操作的清晰读物。 这是可能的,因为原始的确实是可组合函数的链:每个函数的返回值作为唯一参数传递给下一个函数。 使用高阶函数使我们可以将与验证错误有关的逻辑封装在MaybeValid子类型内。 在库服务中,有多个端点具有与此类似的要求,并且可以使用MaybeValid类简化所有端点。

那单子呢?

我之前提到过可怕的单词“ monad”,您可能已经猜到MaybeValid是其中一个,否则我不会提出它。 那么单子到底什么? 首先,我们需要澄清一件事,因为您可能在“单调函数”的上下文中听到了这个词–这是完全不同的用法。 它表示一个带有一个自变量的函数(带有两个自变量的函数是二进位的,而带有三个自变量的函数是三元组的,等等); 这种用法起源于APL,与我们在此讨论的内容无关。 我们正在谈论的monad是一种设计模式。

毫无疑问,您已经熟悉设计模式。 您已经知道的策略,命令,访问者等都是面向对象的设计模式。 Monad是一种功能设计模式。 Monad模式定义了将操作链接在一起的含义,从而使程序员能够构建以一系列步骤处理数据的管道,就像上面的步骤一样:

  1. 从请求中获取ISBN号(可能无效,即格式错误)。
  2. 通过书号的ISBN查找图书(可能无效,即找不到)。
  3. 从检索到的书中创建SingleBookResult DTO。
  4. 将DTO映射到JSON字符串。
  5. 创建一个状态为200且包含JSON的LibraryResponse

每个步骤都可以由monad提供的其他处理规则“装饰”。 在我们的情况下,其他规则是:

  • 仅当值有效时才执行步进操作。
  • 当该值无效时,将传递错误。

终止操作ifInvalid将最终决定返回什么:如果有效,它将返回包装的值,否则它将使用提供的默认值提供程序来根据客户端请求错误构建适当的响应。

正式定义。

更正式地讲,monad模式通常被定义为以下三个组件的组合,这些三个组件一起被称为kleisi三元组:

  • 类型构造函数 ,将每个可能的类型映射到其对应的单子类型。 在Java中,这种措辞没有多大意义。 要理解它,请考虑通用类型,例如: IsbnMaybeValid<Isbn>
  • 一个单元函数 ,将值包装在具有相应单子类型实例的基础类型中,例如: new Valid<Isbn>(isbn)
  • 绑定操作 ,接受一个函数并将其应用于基础类型。 该函数返回一个新的monadic类型,该类型成为绑定操作的返回值,例如: map(book -> new SingleBookResult(book))产生MaybeValid<SingleBookResult>

如果您具有这三个组成部分,那么您就有一个monad。

我听说Monad都是关于封装副作用的。

如果您在学习Haskell时首先遇到Monad模式,那么很可能您会以I / O Monad的形式了解它。 关于I / O的Haskell教程从字面上建议您现在不必担心Monad部分,因为您不需要了解它即可进行I / O。 就个人而言,这只会使我更加担心。 可能正因为如此,学习Haskell的人们认为Monad的目的是封装诸如I / O之类的副作用。 我不会不同意,我无法对此发表评论,但我还没有以这种方式理解Monad模式。

在我看来,Monad包装一个类型化的值(任何类型),并与包装的值分开维护一些其他状态。 我们在这里看到了两个例子。 对于Optional monad,附加状态是该值是否存在。 对于MaybeValid monad,它是值是否有效,如果不是,则是验证错误。 请注意,这里有两种类型:单子类型(例如Optional )和包装类型。

您可以为Monad提供对包装值进行运算的功能。 无论包装类型的类型是什么,函数的参数都必须匹配它。 Monad将其包装后的值传递给函数,并将产生相同monadic类型的新Monad,封装由函数返回的值。 这称为“绑定操作”。 新Monad的包装类型可能有所不同,这很好。 例如,如果您有一个包装DateOptional ,则可以绑定一个将Date映射到String的函数,结果将是一个包装StringOptional 。 如果有一些功能与Monad的附加状态相关联,则Monad将其作为绑定操作的一部分进行处理。 例如,当您将函数传递给空的Optional ,该函数将不会执行; 结果是另一个空的Optional 。 这样,您可以在Monad的上下文中依次调用由一系列不同类型的组成函数组成的链。

最后,Monad为您提供了一种方法,可以在考虑程序的上下文的任何适当方式下,考虑附加的monadic状态,以处理该值。 适当的行为自然是使用一流的函数处理的。 因此,绑定操作中使用的其他功能与Monad中维护的附加状态脱钩,从而摆脱了处理它的所有责任。

换句话说,Monad在您的框中提供了另一个用于创建抽象的工具,可帮助您降低程序的整体复杂性。

下次。

在下一篇文章中,我们将继续对高阶函数进行研究。 我们将看一下curring,以及如何看待它,尽管表面上看起来很神秘,但实际上它非常有用。 为此,我们将解决Clojure中的一项练习,该练习比到目前为止我们在本系列文章中看到的其他练习都要复杂得多。 我们将逐步进行介绍,并了解REPL驱动的开发的强大功能。

翻译自: https://www.javacodegeeks.com/2018/10/functional-style-part-5.html

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值