关于java:实用函数式-Java-PFJ简介

40次阅读

共计 11802 个字符,预计需要花费 30 分钟才能阅读完成。

【注】本文译自:Introduction To Pragmatic Functional Java – DZone Java

实用函数式(Pragmatic Funcational)Java 是一种基于函数式编程概念的古代、十分简洁但可读的 Java 编码格调。

实用函数式 Java (PFJ) 试图定义一种新的习用 Java 编码格调。编码格调,将齐全利用以后和行将推出的 Java 版本的所有性能,并波及编译器来帮忙编写简洁但牢靠和可读的代码。
尽管这种格调甚至能够在 Java 8 中应用,但在 Java 11 中它看起来更加简洁和简洁。它在 Java 17 中变得更具表现力,并受害于每个新的 Java 语言性能。
但 PFJ 不是收费的午餐,它须要开发人员的习惯和办法产生重大扭转。扭转习惯并不容易,传统的命令式习惯尤其难以解决。
这值得么?的确!PFJ 代码简洁、富裕表现力且牢靠。它易于浏览和保护,并且在大多数状况下,如果代码能够编译 – 它能够工作!

实用函数式 Java 的元素

PFJ 源自一本精彩的 Effective Java 书籍,其中蕴含一些额定的概念和约定,特地是源自函数式编程(FP:Functional Programming)。请留神,只管应用了 FP 概念,但 PFJ 并未尝试强制执行特定于 FP 的术语。(只管对于那些有趣味进一步摸索这些概念的人,咱们也提供了参考)。
PFJ 专一于:

  • 加重心理累赘。
  • 进步代码可靠性。
  • 进步长期可维护性。
  • 借助编译器来帮忙编写正确的代码。
  • 让编写正确的代码变得简略而天然,编写不正确的代码尽管依然可能,但应该须要付出致力。

只管指标雄心勃勃,但只有两个要害的 PFJ 规定:

  • 尽可能防止 null
  • 没有业务异样。

上面,更具体地探讨了每个要害规定:

尽可能防止 null(ANAMAP 规定)

变量的可空性是非凡状态之一。它们是家喻户晓的运行时谬误和样板代码的起源。为了打消这些问题并示意可能失落的值,PFJ 应用 Option<T> 容器。这涵盖了可能呈现此类值的所有状况 – 返回值、输出参数或字段。
在某些状况下,例如出于性能或与现有框架兼容性的起因,类可能会在外部应用 null。这些状况必须分明记录并且对类用户不可见,即所有类 API 都应应用 Option<T>
这种办法有几个长处:

  • 可空变量在代码中立刻可见。无需浏览文档、查看源代码或依赖正文。
  • 编译器辨别可为空和不可为空的变量,并避免它们之间的谬误赋值。
  • 打消了 null 查看所需的所有样板。

无业务异样(NBE 规定)

PFJ 仅应用异样来示意致命的、不可复原的(技术)故障的状况。此类异样可能仅出于记录和 / 或失常敞开应用程序的目标而被拦挡。不激励并尽可能防止所有其余异样及其拦挡。
业务异样是非凡状态的另一种状况。为了流传和解决业务级谬误,PFJ 应用 Result<T> 容器。同样,这涵盖了可能呈现谬误的所有状况 – 返回值、输出参数或字段。实际表明,字段很少(如果有的话)须要应用这个容器。
没有任何正当的状况能够应用业务级异样。与通过专用包装办法与现有 Java 库和遗留代码交互。Result<T> 容器蕴含这些包装办法的实现。
无业务异样 规定具备以下长处:

  • 能够返回谬误的办法在代码中立刻可见。无需浏览 文档、查看源代码或剖析调用树,以查看能够抛出哪些异样以及在哪些条件下被抛出。
  • 编译器强制执行正确的错误处理和流传。
  • 简直没有错误处理和流传的样板。
  • 咱们能够为 高兴的日子 场景编写代码,并在最不便的点处理错误 – 异样的原始用意,这一点实际上从未实现过。
  • 代码放弃可组合、易于浏览和推理,在执行流程中没有暗藏的中断或意外的转换——你读到的就是将要执行的

将遗留代码转换为 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);
    }
}

示例结尾的接口是为了上下文清晰而提供的。次要的趣味点是 getUserWithProfile 办法。咱们一步一步来剖析。

  • 第一条语句从用户存储库中检索 user 变量。
  • 因为用户可能不存在于存储库中,因而 user 变量可能为 null。以下 null 查看验证是否是这种状况,如果是,则抛出业务异样。
  • 下一步是检索用户配置文件详细信息。不足细节不被视为谬误。相同,当短少详细信息时,配置文件将应用默认值。

下面的代码有几个问题。首先,如果存储库中不存在值,则返回 null 从接口看并不显著。咱们须要查看文档,钻研实现或猜想这些存储库是如何工作的。
有时应用注解来提供提醒,但这依然不能保障 API 的行为。
为了解决这个问题,让咱们将规定利用于存储库:

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

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

当初无需进行任何猜想 – API 明确告知可能不存在返回值。
当初让咱们再看看 getUserWithProfile 办法。要留神的第二件事是该办法可能会返回一个值或可能会引发异样。这是一个业务异样,因而咱们能够利用该规定。更改的次要指标 – 明确办法可能返回值 谬误的事实:

public Result<UserWithProfile> getUserWithProfile(User.Id userId) {

好的,当初咱们曾经清理了 API,能够开始更改代码了。第一个变动是由 userRepository 当初返回
Option<User> 引起的:

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

当初咱们须要检查用户是否存在,如果不存在,则返回一个谬误。应用传统的命令式办法,代码应该是这样的:

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

}
代码看起来不是很吸引人,但也不比原来的差,所以临时放弃原样。
下一步是尝试转换残余局部的代码:

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

问题来了:详细信息和用户存储在 Option<T> 容器中,因而要组装 UserWithProfile,咱们须要以某种形式提取值。这里可能有不同的办法,例如,应用 Option.fold() 办法。生成的代码必定不会很漂亮,而且很可能会违反规定。
还有另一种办法 – 应用 Option<T> 是具备非凡属性的容器这一事实。
特地是,能够应用 Option.map()Option.flatMap() 办法转换 Option<T> 中的值。此外,咱们晓得,details 值将由存储库提供或替换为默认值。为此,咱们能够应用 Option.or() 办法从容器中提取详细信息。让咱们试试这些办法:

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

当初咱们须要编写最初一步 – 将 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(""));
}

咱们临时将 return 语句中的谬误起因留空,而后再次查看代码。
咱们能够很容易地发现一个问题:咱们必定晓得 userWithProfile 总是存在 – 当 user 不存在时,下面曾经解决了这种状况。咱们怎样才能解决这个问题?
请留神,咱们能够在不检查用户是否存在的状况下调用 user.map()。仅当 user 存在时才会利用转换,否则将被疏忽。这样,咱们能够打消 if(user.isEmpty()) 查看。让咱们在传递给 user.map() 的 lambda 中挪动对 Userdetails 检索和转换到 UserWithProfile 中:

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

当初须要更改最初一行,因为 userWithProfile 可能会缺失。该谬误将与以前的版本雷同,因为仅当 userRepository.findById(userId) 返回的值缺失时,userWithProfile 才会缺失:

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

最初,咱们能够内联 detailsuserWithProfile,因为它们仅在创立后立刻应用一次:

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

请留神缩进如何帮忙将代码分组为逻辑链接的局部。
让咱们来剖析后果代码:

  • 代码更简洁,为 高兴的日子 场景编写,没有明确的谬误或 null 查看,没有烦扰业务逻辑
  • 没有简略的办法能够跳过或防止谬误或 null 查看,编写正确牢靠的代码是间接而天然的。

不太显著的察看:

  • 所有类型都是主动派生的。这简化了重构并打消了不必要的凌乱。如果须要,依然能够增加类型。
  • 如果在某个时候存储库将开始返回 Result<T> 而不是 Option<T>,代码将放弃不变,除了最初一个转换 (toResult) 将被删除。
  • 除了用 Option.or() 办法替换三元运算符之外,后果代码看起来很像如果咱们将传递给 lambda 外部的原始 return 语句中的代码移动到 map() 办法。

最初一个察看对于开始不便地编写(浏览通常不是问题)PFJ 格调的代码十分有用。它能够改写为以下教训规定:在右侧寻找值。比拟一下:

User user = userRepository.findById(userId); // <-- 值在表达式右边

return userRepository.findById(userId)
.map(user -> ...); // <-- 值在表达式左边

这种有用的察看有助于从遗留命令式代码格调向 PFJ 转换。

与遗留代码交互

不用说,现有代码不遵循 PFJ 办法。它抛出异样,返回 null 等等。有时能够从新编写此代码以使其与 PFJ 兼容,但通常状况并非如此。对于内部库和框架尤其如此。

调用遗留代码

遗留代码调用有两个次要问题。它们中的每一个都与违反相应的 PFJ 规定无关:

解决业务异样

Result<T> 蕴含一个名为 lift() 的辅助办法,它涵盖了大多数用例。办法签名看起来是这样:

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

第一个参数是将异样转换为 Cause 实例的函数(反过来,它用于在失败状况下创立 Result<T> 实例)。第二个参数是 lambda,它封装了对须要与 PFJ 兼容的理论代码的调用。
Causesutility 类中提供了最简略的函数,它将异样转换为 Cause 的实例:fromThrowable()。它们能够与 Result.lift() 一起应用,如下所示:

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

解决 null 值返回

这种状况相当简略 – 如果 API 能够返回 null,只需应用 Option.option() 办法将其包装到 Option<T> 中。

提供遗留 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
    );
}

Result<T> 中没有提供随时可用的辅助办法,起因如下:

  • 可能有不同的用例,并且能够抛出不同类型的异样(已检查和未查看)。
  • Cause 转换为不同的特定异样在很大水平上取决于特定的用例。

治理变量作用域

本节将专门介绍在编写 PFJ 格调代码时呈现的各种理论案例。
上面的示例假如应用 Result<T>,但这在很大水平上无关紧要,因为所有思考因素也实用于 Option<T>。此外,示例假设示例中调用的函数被转换为返回 Result<T> 而不是抛出异样。

嵌套作用域

函数格调代码大量应用 lambda 来执行 Option<T>Result<T> 容器内的值的计算和转换。每个 lambda 都隐式地为其参数创立了作用域——它们能够在 lambda 主体外部拜访,但不能在其内部拜访。
这通常是一个有用的属性,但对于传统的命令式代码,它很不寻常,一开始可能会感觉不不便。侥幸的是,有一种简略的技术能够克服感知上的不便。
咱们来看看上面的命令式代码:

var value1 = function1(...); // function1()
 可能抛出异样
var value2 = function2(value1, ...); // function2() 可能抛出异样
var value3 = function3(value1, value2, ...); // function3() 可能抛出异样

变量 value1 应该可拜访以调用 function2() 和 function3()。这的确意味着间接转换为 PFJ 款式将不起作用:

function1(...)
.flatMap(value1 -> function2(value1, ...))
.flatMap(value2 -> function3(value1, value2, ...)); // <-- 错, value1 不可拜访

为了放弃值的可拜访性,咱们须要应用嵌套作用域,即嵌套调用如下:

function1(...)
.flatMap(value1 -> function2(value1, ...)
    .flatMap(value2 -> function3(value1, value2, ...)));

第二次调用 flatMap() 是针对 function2 返回的值而不是第一个 flatMap() 返回的值。通过这种形式,咱们将 value1 放弃在范畴内,并使 function3 能够拜访它。
只管能够创立任意深度的嵌套作用域,但通常多个嵌套作用域更难浏览和遵循。在这种状况下,强烈建议将更深的范畴提取到专用函数中。

平行作用域

另一个常常察看到的状况是须要计算 / 检索几个独立的值,而后进行调用或构建一个对象。让咱们看看上面的例子:

var value1 = function1(...);    // function1() 可能抛出异样
var value2 = function2(...);    // function2() 可能抛出异样
var value3 = function3(...);    // function3() 可能抛出异样
return new MyObject(value1, value2, value3);

乍一看,转换为 PFJ 款式能够与嵌套作用域完全相同。每个值的可见性将与命令式代码雷同。可怜的是,这会使范畴嵌套很深,尤其是在须要获取许多值的状况下。
对于这种状况,Option<T>Result<T> 提供了一组 all() 办法。这些办法执行所有值的“并行”计算并返回 MapperX<...> 接口的专用版本。这个接口只有三个办法—— id()map()flatMap()map()flatMap() 办法的工作形式与 Option<T>Result<T> 中的相应办法完全相同,只是它们承受具备不同数量参数的 lambda。让咱们来看看它在实践中是如何工作的,并将下面的命令式代码转换为 PFJ 款式:

return Result.all(function1(...),
          function2(...),
          function3(...)
        ).map(MyObject::new);

除了紧凑和 扁平 之外,这种办法还有一些长处。首先,它明确表白用意——在应用前计算所有值。命令式代码按程序执行此操作,暗藏了原始用意。第二个长处 – 每个值的计算是独立的,不会将不必要的值带入范畴。这缩小了了解和推理每个函数调用所需的上下文。

代替作用域

一个不太常见但依然很重要的状况是咱们须要检索一个值,但如果它不可用,那么咱们应用该值的代替起源。当有多个代替计划可用时,这种状况的频率甚至更低,而且在波及错误处理时会更加苦楚。
咱们来看看上面的命令式代码:

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}
    }
}

代码是人为设计的,因为嵌套案例通常暗藏在其余办法中。尽管如此,整体逻辑并不简略,次要是因为除了抉择值之外,咱们还须要处理错误。错误处理使代码变得凌乱,并使初始用意 – 抉择第一个可用的代替计划 – 暗藏在错误处理中。
转变为 PFJ 格调使用意十分清晰:

var value = Result.any(function1(...),
        function2(...),
        function3(...)
    );

可怜的是,这里有一个重要的区别:原始命令式代码仅在必要时计算第二个和后续代替项。在某些状况下,这不是问题,但在许多状况下,这是十分不可取的。侥幸的是,Result.any() 有一个惰性版本。应用它,咱们能够重写代码如下:

var value = Result.any(function1(...),
        () -> function2(...),
        () -> function3(...)
    );

当初,转换后的代码的行为与它的命令式对应代码齐全一样。

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

这两个容器在函数式编程术语中是单子(monad)。
Option<T>Option/Optional/Maybe monad 的间接实现。
Result<T>Either<L,R> 的特意简化和专门版本:左类型是固定的,应该实现 Cause 接口。专业化使 API 与 Option<T> 十分类似,并以失去通用性为代价打消了许多不必要的输出。
这个特定的实现集中在两件事上:

  • 与现有 JDK 类(如 Optional<T>Stream<T>)之间的互操作性
  • 用于明确用意表白的 API

最初一句话值得更深刻的解释。
每个容器都有几个 外围 办法:

  • 工厂办法
  • map() 转换方法,转换值但不扭转非凡状态:present Option<T> 放弃 present,success Result<T> 放弃 success
  • flatMap() 转换方法,除了转换之外,还能够扭转非凡状态:将 Option<T> present 转换为 empty 或将 Result<T> success 转换为 failure
  • fold() 办法,它同时解决两种状况(Option<T>present/emptyResult<T>success/failure)。

除了外围办法,还有一堆 辅助 办法,它们在常常察看到的用例中很有用。
在这些办法中,有一组办法是明确设计来产生 副作 用的。
Option<T> 有以下 副作用 的办法:

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

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

这些办法向读者提供了代码解决副作用而不是转换的提醒。

其余有用的工具

除了 Option<T>Result<T> 之外,PFJ 还应用了一些其余通用类。上面,将对每种办法进行更具体地形容。

Functions(函数)

JDK 提供了许多有用的性能接口。可怜的是,通用函数的函数式接口仅限于两个版本:单参数 Function<T, R> 和两个参数 BiFunction<T, U, R>
显然,这在许多理论状况中是不够的。此外,出于某种原因,这些函数的类型参数与 Java 中函数的申明形式相同:后果类型列在最初,而在函数申明中,它首先定义。
PFJ 为具备 1 到 9 个参数的函数应用一组统一的函数接口。为简洁起见,它们被称为 FN1…FN9。到目前为止,还没有更多参数的函数用例(通常这是代码异味)。但如果有必要,该清单能够进一步扩大。

Tuples(元组)

元组是一种非凡的容器,可用于在单个变量中存储多个不同类型的值。与类或记录不同,存储在其中的值没有名称。这使它们成为在保留类型的同时捕捉任意值集的不可或缺的工具。这个用例的一个很好的例子是 Result.all() Option.all() 办法集的实现。
在某种意义上,元组能够被认为是为函数调用筹备的 一组解冻的参数。从这个角度来看,让元组外部值只能通过 map() 办法拜访的决定听起来很正当。然而,具备 2 个参数的元组具备额定的拜访器,能够应用 Tuple2<T1,T2> 作为各种 Pair<T1,T2> 实现的代替。
PFJ 应用一组统一的元组实现,具备 0 到 9 个值。提供具备 0 和 1 值的元组以放弃一致性。

论断

实用函数式 Java 是一种基于函数式编程概念的古代、十分简洁但可读的 Java 编码格调。与传统的习用 Java 编码格调相比,它提供了许多益处:

  • PFJ 借助 Java 编译器来帮忙编写牢靠的代码:

    • 编译的代码通常是无效的
    • 许多谬误从运行时转移到编译时
    • 某些类别的谬误,例如 NullPointerException 或未解决的异样,实际上已被打消
  • PFJ 显着缩小了与谬误流传和解决以及 null 查看相干的样板代码量
  • PFJ 专一于清晰表白用意并缩小心理累赘

正文完
 0