发布于 2026-01-06 1 阅读
0

实用函数式 Java 简介 DEV 的全球展示挑战赛,由 Mux 呈现:展示你的项目!

实用函数式 Java 入门

由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!

更新:添加了关于默认初始化的重要说明

实用函数式Java(PFJ)旨在定义一种新的Java惯用编码风格。这种编码风格将充分利用当前及未来Java版本的所有特性,并借助编译器来编写简洁、可靠且易读的代码。

虽然这种风格在 Java 8 中也能使用,但在 Java 11 中看起来更加简洁明了。到了 Java 17,它的表现力更加丰富,并且充分利用了 Java 语言的每一项新特性。

但PFJ并非免费午餐,它需要开发者彻底改变习惯和方法。改变习惯并非易事,尤其是传统的命令式习惯,更是难以改变。

值得吗?当然值得!PFJ 代码简洁、表达力强、可靠,易于阅读和维护。大多数情况下,只要代码能编译,就能正常运行!

(本文是Pragmatica文库的组成部分)。

实用函数式 Java 的基本要素

PFJ 源自优秀的《Effective Java》一书,并添加了
一些概念和约定,特别是源自函数式编程的概念和约定。

请注意,尽管PFJ使用了FP概念,但它并不强制使用FP特有的术语。(不过,
我们为有兴趣进一步了解这些概念的人提供了参考资料。)

PFJ 的关注点是:

  • 降低心理负担
  • 提高代码可靠性
  • 提高长期可维护性
  • 利用编译器来帮助编写正确的代码
  • 让编写正确的代码变得轻松自然;编写错误的代码虽然仍然可能,但应该需要付出努力。

尽管目标远大,但PFJ只有两条关键规则:

  • null尽量避免
  • 没有例外情况

下面将对每条关键规则进行更详细的探讨:

null尽可能避免(ANAMAP 原则)

变量可为空是 PFJ 的一种特殊状态
众所周知,它们是运行时错误和冗余代码的常见来源。为了消除这些问题并表示可能缺失的值,PFJ 使用了Option容器。这涵盖了所有可能出现此类值的情况——返回值、输入参数或字段。

在某些情况下,例如出于性能或与现有框架兼容性的考虑,类可能会null在内部使用。这些情况必须有明确的文档说明,并且对类用户不可见,也就是说,所有类 API 都应该使用Option<T>

这种方法有几个优点:

  • 可为空的变量在代码中立即可见。无需阅读文档/查看源代码/依赖注解。
  • 编译器能够区分可空变量和非空变量,并防止对它们进行错误的赋值。
  • 所有必要的null检查性配置代码都被删除。

ANAMAP 规则的重要组成部分:

  • 没有默认初始化。每个变量都应该显式初始化。这样做有两个原因:保持上下文一致和避免重复null赋值。

无业务例外(NBE 规则)

PFJ 仅使用异常来表示致命的、不可恢复的(技术性)故障。此类异常可能仅用于记录日志和/或优雅地关闭应用程序。所有其他异常及其拦截均不鼓励使用,并应尽可能避免。

业务异常是特殊状态的另一种情况。PFJ使用结果容器
来传播和处理业务级别的错误。

同样,这涵盖了所有可能出现错误的情况——返回值、输入参数或字段。实践表明,字段很少(甚至几乎不需要)使用此容器。

没有正当理由可以使用业务级异常。与现有 Java 库和遗留代码的交互通过专用的包装方法实现。结果容器包含这些包装方法的实现。

No Business Exceptions规则具有以下优点:

  • 代码中会立即显示可能返回错误的方法。无需阅读文档/查看源代码/分析调用树来了解会抛出哪些异常以及在什么情况下会抛出异常。
  • 编译器强制执行正确的错误处理和传播。
  • 错误处理和传播几乎没有样板代码。
  • 可以编写代码来应对一切顺利的情况,并在最方便的时候处理错误——这是异常的最初目的,但实际上从未实现。
  • 代码保持可组合性,易于阅读和理解,执行流程中没有隐藏的中断或意外的转换——你所读到的就是将要执行的

将传统代码转换为 PFJ 风格代码

好的,关键规则看起来不错也很有用,但是实际代码会是什么样子呢?

让我们从一段非常典型的后端代码开始:

public interface UserRepository {
    User findById(User.Id userId);
}

public interface UserProfileRepository {
    UserProfile findById(User.Id userId);
}

public class UserService {
    private final UserRepository userRepository;
    private final UserProfileRepository userProfileRepository;

    public UserWithProfile getUserWithProfile(User.Id userId) {
        User user = userRepository.findById(userId);

        if (user == null) {
            throw UserNotFoundException("User with ID " + userId + " not found");
        }

        UserProfile details = userProfileRepository.findById(userId);

        return UserWithProfile.of(user, details == null 
            ? UserProfile.defaultDetails()
            : details);
    }
}
Enter fullscreen mode Exit fullscreen mode

示例开头提供的接口是为了便于理解上下文。

关键在于getUserWithProfile方法。让我们一步一步地分析它。

  • user第一条语句从用户存储库中检索变量。
  • 由于用户可能不在存储库中,因此user变量可能为空null。以下null检查会验证是否存在这种情况,如果是,则抛出业务异常。
  • 下一步是获取用户个人资料详情。缺少详情不视为错误。相反,如果缺少详情,则会使用个人资料的默认值。

上面的代码存在几个问题。首先,null如果仓库中不存在该值,如何返回结果,这一点从接口中并不明显。我们需要查阅文档、研究实现,或者猜测这些仓库的工作原理。
有时会使用注解来提供一些提示,但这仍然不能保证 API 的行为。

为了解决这个问题,让我们对存储库应用ANAMAP规则:

public interface UserRepository {
    Option<User> findById(User.Id userId);
}

public interface UserProfileRepository {
    Option<UserProfile> findById(User.Id userId);
}
Enter fullscreen mode Exit fullscreen mode

现在无需进行任何猜测——API明确指出返回值可能不存在。

现在我们再来看一下getUserWithProfile这个方法。需要注意的第二点是,该方法可能返回一个值,也可能抛出一个异常。这是一个业务异常,因此我们可以应用NBE规则。此次更改的主要目标是明确指出方法可能返回值
错误 这一事实

    public Result<UserWithProfile> getUserWithProfile(User.Id userId) {
Enter fullscreen mode Exit fullscreen mode

好了,现在 API 已经清理完毕,可以开始修改代码了。第一个改动是由以下事实引起的:userRepository现在该事实返回Option<User>

    public Result<UserWithProfile> getUserWithProfile(User.Id userId) {
        Option<User> user = userRepository.findById(userId);
    }
Enter fullscreen mode Exit fullscreen mode

现在我们需要检查用户是否存在,如果不存在,则返回错误。使用传统的命令式编程方法,代码应该如下所示:

    public Result<UserWithProfile> getUserWithProfile(User.Id userId) {
        Option<User> user = userRepository.findById(userId);

        if (user.isEmpty()) {
            return Result.failure(Causes.cause("User with ID " + userId + " not found"));
        }
    }
Enter fullscreen mode Exit fullscreen mode

这段代码看起来不太吸引人,但也不比原代码差,所以暂时先这样吧。

下一步是尝试转换剩余的代码部分:

    public Result<UserWithProfile> getUserWithProfile(User.Id userId) {
        Option<User> user = userRepository.findById(userId);

        if (user.isEmpty()) {
            return Result.failure(Causes.cause("User with ID " + userId + " not found"));
        }

        Option<UserProfile> details = userProfileRepository.findById(userId);

    }
Enter fullscreen mode Exit fullscreen mode

问题在于:详细信息和用户信息存储在Option<T>容器中,因此UserWithProfile我们
需要以某种方式提取这些值才能进行组装。这里可以有不同的方法,例如使用Option.fold()方法。
但最终得到的代码肯定不会美观,而且很可能违反ANAMAP规则。

还有另一种方法——利用Option<T>容器的特殊属性
具体来说,可以使用 ` Option<T>transform`Option.map()和 ` Option.flatMap()transform` 方法转换容器内的值。
此外,我们知道该details值要么由存储库提供,要么会被替换为默认值。为此,我们可以使用
Option.or()`extract` 方法从容器中提取详细信息。
让我们尝试以下方法:

    public Result<UserWithProfile> getUserWithProfile(User.Id userId) {
        Option<User> user = userRepository.findById(userId);

        if (user.isEmpty()) {
            return Result.failure(Causes.cause("User with ID " + userId + " not found"));
        }

        UserProfile details = userProfileRepository.findById(userId).or(UserProfile.defaultDetails());

        Option<UserWithProfile> userWithProfile =  user.map(userValue -> UserWithProfile.of(userValue, details));

    }
Enter fullscreen mode Exit fullscreen mode

现在我们需要编写最后一步——将userWithProfile容器Option<T>Result<T>

    public Result<UserWithProfile> getUserWithProfile(User.Id userId) {
        Option<User> user = userRepository.findById(userId);

        if (user.isEmpty()) {
            return Result.failure(Causes.cause("User with ID " + userId + " not found"));
        }

        UserProfile details = userProfileRepository.findById(userId).or(UserProfile.defaultDetails());

        Option<UserWithProfile> userWithProfile =  user.map(userValue -> UserWithProfile.of(userValue, details));

        return userWithProfile.toResult(Cause.cause(""));
    }
Enter fullscreen mode Exit fullscreen mode

我们先将return语句中的错误原因留空,再看一下代码。
我们可以很容易地发现问题:我们确定 `is`userWithProfile始终存在——当user`is` 不存在时的情况,上面已经处理过了。我们该如何解决这个问题呢?

注意,我们可以user.map()在不检查用户是否存在的情况下调用此方法。转换仅在user用户存在时应用,否则将被忽略。这样,我们就可以消除if(user.isEmpty())检查。让我们将用户的检索details和转换移到User传递UserWithProfile给该方法的 lambda 表达式内部user.map()

    public Result<UserWithProfile> getUserWithProfile(User.Id userId) {
        Option<UserWithProfile> userWithProfile = userRepository.findById(userId).map(userValue -> {
            UserProfile details = userProfileRepository.findById(userId).or(UserProfile.defaultDetails());
            return UserWithProfile.of(userValue, details);
        });

        return userWithProfile.toResult(Cause.cause(""));
    }
Enter fullscreen mode Exit fullscreen mode

最后一行需要修改,因为userWithProfile它可能缺失。错误信息与之前的版本相同,因为userWithProfile只有当返回值userRepository.findById(userId)缺失时,该行才可能缺失:

    public Result<UserWithProfile> getUserWithProfile(User.Id userId) {
        Option<UserWithProfile> userWithProfile = userRepository.findById(userId).map(userValue -> {
            UserProfile details = userProfileRepository.findById(userId).or(UserProfile.defaultDetails());
            return UserWithProfile.of(userValue, details);
        });

        return userWithProfile.toResult(Causes.cause("User with ID " + userId + " not found"));
    }
Enter fullscreen mode Exit fullscreen mode

最后,我们可以内联使用details`and`userWithProfile语句,因为它们只会在创建后立即使用一次:

    public Result<UserWithProfile> getUserWithProfile(User.Id userId) {
        return userRepository.findById(userId)
            .map(userValue -> UserWithProfile.of(userValue, userProfileRepository.findById(userId)
                                                                                 .or(UserProfile.defaultDetails())))
            .toResult(Causes.cause("User with ID " + userId + " not found"));
    }
Enter fullscreen mode Exit fullscreen mode

注意缩进如何帮助将代码分组为逻辑上相互关联的部分。

让我们来分析生成的代码。

  • 代码更简洁,编写时更注重逻辑清晰性happy day scenario,没有显式的错误null检查,不会分散对业务逻辑的注意力。
  • 没有简单的方法可以跳过或避免错误或null检查,编写正确可靠的代码是直接而自然的。

不太明显的观察结果:

  • 所有类型都会自动派生。这简化了重构并消除了不必要的冗余。如有必要,仍然可以添加类型。
  • 如果将来某个时候存储库开始返回Result<T>而不是Option<T>,则代码将保持不变,只是最后一个转换(toResult)将被删除。
  • 除了将三元运算符替换为Option.or()方法之外,生成的代码看起来很像将原始return语句中的代码移动到传递给map()方法的 lambda 表达式中。

最后一点观察对于方便地编写(阅读通常不是问题)PFJ 风格代码非常有用。
它可以重写为以下经验法则:在右侧查找值。只需比较:

 User user = userRepository.findById(userId);    // <-- value is on the left side of the expression
Enter fullscreen mode Exit fullscreen mode


 return userRepository.findById(userId)
                      .map(user -> ...); // <-- value is on the right side of the expression
Enter fullscreen mode Exit fullscreen mode

这一有用的观察有助于从传统的命令式代码风格过渡到 PFJ。

与遗留代码交互

毋庸置疑,现有代码并不符合 PFJ 的设计理念。它会抛出异常、返回值null等等。
有时可以修改这段代码使其兼容 PFJ,但通常情况下并非如此。对于外部库和框架而言,这种情况尤为突出。

调用遗留代码

遗留代码调用存在两个主要问题。这两个问题都与违反相应的 PFJ 规则有关:

处理业务异常

该类Result<T>包含一个名为 `get` 的辅助方法,lift()该方法涵盖了大多数使用场景。方法签名如下:

static <R> Result<R> lift(FN1<? extends Cause, ? super Throwable> exceptionMapper, ThrowingSupplier<R> supplier)
Enter fullscreen mode Exit fullscreen mode

第一个参数是将异常转换为实例的函数Cause(该实例又
用于Result<T>在失败情况下创建实例)。

第二个参数是 lambda 表达式,它封装了对实际代码的调用,这些代码需要与 PFJ 兼容。

工具类Cause中提供了一个最简单的函数,可以将异常转换为实例: 。它们可以按如下方式一起使用:Causes
fromThrowable()Result.lift()

public static Result<URI> createURI(String uri) {
    return Result.lift(Causes::fromThrowable, () -> URI.create(uri));
}
Enter fullscreen mode Exit fullscreen mode

处理null返回值

这种情况比较简单——如果 API 可以返回null,只需将其包装在Option<T>usingOption.option()方法中即可。

提供传统 API

有时需要允许旧代码调用以 PFJ 风格编写的代码。这种情况尤其常见于
某些较小的子系统转换为 PFJ 风格,而系统的其余部分仍然以旧风格编写,需要保留 API 时

最便捷的方法是将实现拆分为两部分——PFJ 风格的 API 和适配器,适配器仅负责将新 API 适配到旧 API。这里可以使用一个非常简单的辅助方法,例如下面所示的方法:

public static <T> T unwrap(Result<T> value) {
    return value.fold(
        cause -> { throw new IllegalStateException(cause.message()); },
        content -> content
    );
}
Enter fullscreen mode Exit fullscreen mode

Result<T>由于以下原因,没有提供现成的辅助方法:

  • 可能会有不同的使用场景,并且会抛出不同类型的异常(已检查异常和未检查异常)。
  • 将异常情况转化Cause为不同的具体异常情况,很大程度上取决于具体的使用案例。

管理可变范围

本节将专门讨论在编写 PFJ 风格代码时出现的各种实际情况。

以下示例假设使用了 `<T>` Result<T>,但这在很大程度上无关紧要,因为所有注意事项也适用于 `<T>` Option<T>。此外,示例假设示例中调用的函数已转换为返回值Result<T>而不是抛出异常。

嵌套作用域


函数式编程风格大量使用 lambda 表达式来对容器内部Option<T>值进行计算和转换Result<T>。每个 lambda 表达式都会隐式地为其参数创建一个作用域——它们可以在 lambda 表达式内部访问,但在外部则不可访问。
这通常是一个很有用的特性,但对于传统的命令式编程来说却相当不寻常,起初可能会让人感到不便。幸运的是,有一种简单的技巧可以克服这种不便。

让我们来看一下下面的命令式代码:

var value1 = function1(...);                    // function1() may throw exception
var value2 = function2(value1, ...);            // function2() may throw exception
var value3 = function3(value1, value2, ...);    // function3() may throw exception
Enter fullscreen mode Exit fullscreen mode

变量value1应该可以用于调用 ` function2()and`函数function3()。这意味着直接转换为 PFJ 风格是行不通的:

   function1(...)
       .flatMap(value1 -> function2(value1, ...))
       .flatMap(value2 -> function3(value1, value2, ...)); // <-- ERROR, value1 is not accessible!  
Enter fullscreen mode Exit fullscreen mode

为了保持值的可访问性,我们需要使用嵌套作用域,即嵌套调用,如下所示:

   function1(...)
       .flatMap(value1 -> function2(value1, ...)
           .flatMap(value2 -> function3(value1, value2, ...)));   
Enter fullscreen mode Exit fullscreen mode

第二次调用flatMap()是为了获取第一个函数返回的值,function2而不是第一个函数返回的值flatMap()。这样可以确保value1函数的作用域不变,并使其可供其他函数访问function3

虽然可以创建任意深度的嵌套作用域,但通常嵌套超过两层作用域后,代码会变得难以阅读和理解。在这种情况下,强烈建议将更深层的作用域提取到单独的函数中。

并行示波器

另一种常见情况是需要计算/检索多个独立值,然后进行调用或构建对象。让我们来看下面的例子:

var value1 = function1(...);    // function1() may throw exception
var value2 = function2(...);    // function2() may throw exception
var value3 = function3(...);    // function3() may throw exception

return new MyObject(value1, value2, value3);
Enter fullscreen mode Exit fullscreen mode

乍一看,PFJ 风格的转换似乎与嵌套作用域的处理方式完全相同。每个值的可见性也与命令式代码相同。然而,这会导致作用域嵌套过深,尤其是在需要获取大量值的情况下。

针对这种情况,Option<T>我们Result<T>提供了一组方法。这些方法 对所有值all()执行“并行”计算,并返回接口的专用版本。此接口只有三个方法:`__getitem__` `__getitem__` 和 `__getitem__`。`__getitem__`和 `__getitem__`方法的工作方式`__getitem__` 和 `__getitem__` 中的相应方法完全相同,只是它们接受的 lambda 表达式参数数量不同。让我们看看它在实践中是如何工作的,并将上面的命令式代码转换为 PFJ 风格:
MapperX<...>id()map()flatMap()map()flatMap()Option<T>Result<T>

return Result.all(
          function1(...), 
          function2(...), 
          function3(...)
        ).map(MyObject::new);
Enter fullscreen mode Exit fullscreen mode

除了简洁扁平之外这种方法还有其他几个优点。首先,它明确地表达了意图——在使用前计算所有值。命令式代码是顺序执行的,隐藏了最初的意图。
其次,每个值的计算都是独立的,不会引入不必要的值。这减少了
理解和推理每个函数调用所需的上下文信息。

替代范围

还有一种不太常见但同样重要的情况:我们需要获取某个值,但如果该值不可用,则需要使用其他值来源。当存在多个备选方案时,这种情况就更加罕见,但一旦涉及到错误处理,就会变得更加棘手。

让我们来看一下下面的命令式代码:


MyType value;

try {
    value = function1(...);
} catch (MyException e1) {
    try {
        value = function2(...);    
    } catch(MyException e2) {
        try {
            value = function3(...);
        } catch(MyException e3) {
            ... // repeat as many times as there are alternatives 
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

这段代码有些牵强,因为嵌套的 case 通常隐藏在其他方法内部。然而,整体逻辑远非简单,主要是因为除了选择值之外,我们还需要处理错误。错误处理使代码变得臃肿,并将最初的意图——选择第一个可用的选项——隐藏在了错误处理代码之中。

转变为PFJ型人格后,意图就变得非常清晰:

var value = Result.any(
        function1(...),
        function2(...),
        function3(...)
    );
Enter fullscreen mode Exit fullscreen mode

遗憾的是,这里存在一个重要的区别:原始的命令式代码仅在必要时才计算第二个及后续选项。在某些情况下,这不成问题,但在很多情况下,这是非常不理想的。幸运的是,有一个惰性求值的版本Result.any()。​​使用它,我们可以将代码重写如下:

var value = Result.any(
        function1(...),
        () -> function2(...),
        () -> function3(...)
    );
Enter fullscreen mode Exit fullscreen mode

现在,转换后的代码的行为与对应的命令式代码完全一样。

简要技术概述Option<T>Result<T>

从函数式编程的角度来看,这两个容器都是单子。

Option<T>这是对 monad 的一种相当直接的实现Option/Optional/Maybe

Result<T>这是一个有意简化和专门化的版本Either<L,R>:左侧类型是固定的,并且应该实现Cause接口。这种专门化使得 API 与 非常相似Option<T>,并消除了许多不必要的类型输入,但代价是损失了通用性和一般性。

此具体实施方案主要关注两点:

  • 它们之间以及与现有 JDK 类(例如Optional<T>)之间的互操作性Stream<T>
  • 旨在清晰表达意图的API

最后一点值得更深入的解释。

每个容器都包含一些核心方法:

  • 工厂方法
  • map()转换方法,转换值但不改变特殊状态:存在Option<T>仍然是存在,成功Result<T>仍然是成功。
  • flatMap()转换方法,除了转换之外,还可以改变特殊状态:将存在Option<T>变为空,或将成功Result<T>变为失败。
  • fold()该方法同时处理两种情况(存在/为空Option<T>和成功/失败)。Result<T>

除了核心方法之外,还有许多辅助方法,它们在常见的使用场景中非常有用。
在这些方法中,有一组方法专门设计用于产生副作用

Option<T>有以下几种应对副作用的方法:

Option<T> whenPresent(Consumer<? super T> consumer);
Option<T> whenEmpty(Runnable action);
Option<T> apply(Runnable emptyValConsumer, Consumer<? super T> nonEmptyValConsumer);
Enter fullscreen mode Exit fullscreen mode

Result<T>有以下几种应对副作用的方法:

Result<T> onSuccess(Consumer<T> consumer);
Result<T> onSuccessDo(Runnable action);
Result<T> onFailure(Consumer<? super Cause> consumer);
Result<T> onFailureDo(Runnable action);
Result<T> apply(Consumer<? super Cause> failureConsumer, Consumer<? super T> successConsumer);
Enter fullscreen mode Exit fullscreen mode

这些方法向读者暗示,代码处理的是副作用而不是转换。

其他实用工具

除了 ` Option<T>A` 和 `B`之外Result<T>,PFJ 还使用了一些其他通用类。下面将详细介绍每个类。

函数

JDK 提供了许多有用的函数式接口。遗憾的是,通用函数的函数式接口仅限于两种版本:单参数Function<T, R>和双参数BiFunction<T, U, R>

显然,这在许多实际情况下是不够的。此外,不知何故,这些函数的类型参数与 Java 中函数的声明方式相反:结果类型列在最后,而函数声明中它是首先定义的。

PFJ 使用一套统一的函数式接口来表示参数数量为 1 到 9 的函数。为了简洁起见,我们将其称为FN1…… FN9。目前为止,还没有使用参数数量更多函数的用例(通常来说,这被认为是一种不好的代码风格)。但如果将来有必要,可以进一步扩展这个列表。

元组

元组是一种特殊的容器,可用于在单个变量中存储多个不同类型的值。与类或记录不同,元组中存储的值没有名称。这使得元组成为捕获任意值集合并保持其类型的必要工具。实现方法集就是一个很好的Result.all()例子Option.all()

从某种意义上说,元组可以被视为一组预先准备好的、用于函数调用的参数。从这个角度来看,仅通过方法访问元组内部值的决定map()是合理的。然而,具有两个参数的元组还具有额外的访问器,这使得它可以作为Tuple2<T1,T2>各种Pair<T1,T2>实现的替代方案。

PFJ 使用一套一致的元组实现,其值范围为 0 到 9。为了保持一致性,也提供了值为 0 和 1 的元组。

结论

实用函数式Java是一种基于函数式编程概念的现代、简洁且易读的Java编码风格。与传统的惯用Java编码风格相比,它具有以下诸多优势:

  • PFJ 包含 Java 编译器,有助于编写可靠的代码:
    • 能编译的代码通常都能正常运行。
    • 许多错误从运行时转移到了编译时。
    • 某些类型的错误,例如NullPointerException未处理的异常,几乎完全消除。
  • PFJ显著减少了与错误传播和处理相关的样板代码量,以及null检查次数。
  • PFJ 型人格注重清晰表达意图和减少心理负担。
文章来源:https://dev.to/siy/introduction-to-pragmatic-function-java-142m