Java中具有纯函数的Pure Bliss


您不需要花几个小时就可以了解方法,类或包的功能。如果您甚至需要花很多心思才能开始编程,那么在产生质量之前,您将耗尽精力。减少代码的认知负担,您将减少其错误数量。

“这是纯粹的废话!” 你说。“智力自慰!应该以真实的可变对象为模型建模!”

我并不是说您应该继续追求愿景,并以硬核函数程序员的身份返回。那不是很有效。功能和面向对象的编程可以相互补充。

我会告诉你如何。

但是首先,让我们了解它的原因和原因。

什么? 纯函数是具有以下功能的函数:

…在相同的输入下,总是返回相同的结果。 ……没有任何副作用。 简而言之:当使用输入A调用纯函数时,无论您调用它的频率,时间和位置如何,它总是返回B。另外,它什么也没有做。

image-01.png

在Java中,纯函数可能看起来像这样:

public static int sum(int a, int b) {
    return a + b;
}

如果ais2和bis 3,则结果始终为5,无论您调用它的频率是多少或速度有多快,即使同时进行也是如此。

作为反例,这将是一个不纯函数:

public static int sum(int a, int b) {
    return new Random().nextInt() + a + b;
}

无论输入如何,其输出都可以是任何东西。它违反了第一条规则。如果这是程序的一部分,该程序将几乎无法推理。

image-02.png

第二个规则的示例,该函数具有副作用:

public static int sum(int a, int b) {
    writeSomethingToFile();
    return a + b;
}

即使此方法是静态的并且始终返回相同的值,它也不是纯洁的。它做得比广告还多。更糟糕的是,它是秘密进行的。

image-03.png

这些示例看起来很简单且无害。但是杂质很快就累加了。

为什么? 假设您有一个要添加新功能的大小合适的程序。在您这样做之前,您必须知道它的作用。您可能会首先查看代码,阅读文档,进行方法调用等等。

在执行此操作时,您正在创建程序的思维模型。您正在尝试将所有可能的数据流放入您的脑海。人脑并未针对此任务进行优化。在这里进行了一些状态更改后,在此出现了一些条件之后,您开始变得有些模糊。

image-05.png

现在,我们为错误提供了温床。

那么如何防止这种情况呢?通过降低我们程序的复杂性,适合初学者。纯函数在这里可以有很大的帮助。

如何? 澄清复杂代码的一种好方法是将其分解为更小,更易管理的部分。理想情况下,每一点都有自己的责任。这使他们更容易推理,特别是如果我们可以单独测试它们的话。

在理清意大利面的同时,您会发现自己现在可以释放一些智力。您正在慢慢找到问题的核心。可以独立运行的每个片段都可以移动,您将拥有程序的本质。

现在发现该错误要容易得多。因此,添加了一个很酷的新功能。为了使事情更具体,这里有一些指导原则。

Partition 首先,按功能划分较大的部分。考虑解决方案的各个部分以及它们之间的相互联系。

image-06.png

Break Up 将这些部分分解为可组合的单元。定义一个可用于从列表中过滤项目的功能,或一个可对每个项目重复使用的操作。也许添加了辅助功能,这些功能封装了否则将最终嵌入深层嵌套代码中的逻辑。

image-07.png

文档 当您觉得自己已“完成”实现单元时,请编写文档。这将帮助您从不同的角度看待逻辑,并揭示无法预料的边缘情况。如有必要,添加单元测试以进一步定义程序的意图。

image-08.png

冲洗并重复。

完成的定义是个人的,可以随时间变化。不要过度使用它。如果仍然不够清晰,您稍后会发现。如果不在代码审查期间,则可能是在向项目添加新功能时。

到目前为止,一切都很好,对吗?理论总是听起来很棒。但是,我们需要以实际示例的形式令人信服。

一个例子 假设我们有一个销售IoT设备的客户。客户可以在家里安装这些设备,然后将它们连接到互联网。他们可以通过手机上的应用程序控制这些设备。

如果他们的设备离线一段时间,我们的客户希望他们的客户收到推送通知,因此他们有机会重新连接设备。如果它保持脱机状态,则他们希望定期推送通知,以提醒客户重新连接设备。但不是太经常了,因为他们不希望不稳定的设备引起无休止的警告流。

另外,我们的客户希望能够调整发送这些消息的阈值。在运行时,无需支付我们为他们做的费用。应该至少有一个阈值,但是可以有任意数量的阈值。

使用我们的后端软件,我们可以检测到这些物联网设备的在线状态。如果他们下线,我们将存储他们下线的时间。

Naive Implementation 我们如何实现呢?这个功能在纸上看起来很简单,因此我们可以开始并了解我们能走多远。

在做完一些脚手架之后,我们迅速找到了问题的核心:确定是否应为每个离线设备发送通知。听起来很简单,对吧?

public void run() {
    // @todo Send notifications for offline devices
}

我们在这里添加一些for循环。

for (Map.Entry<Device, Instant> offlineDevice : offlineDevices.entrySet()) {
    for (Duration threshold : thresholds) {
        // ...
    }
}

一些if语句在那里。

if (firstThresholdWasPassed) {
    // ...
}

看起来不错!

等待,最后一个阈值有一个特殊情况。

if (i == thresholds.size()) {
    // ...
}

哦,还有一个门槛。

if (thresholds.size() == 1) {
    // ...
}

糟糕,我们忘记了检查每个阈值的通知是否已经发送。在3个地方。

if (!lastOfflineNotificationInstant.isPresent()) {
    // ...
}

如果它是在设备离线之前发送的,该怎么办?

if (Duration.between(disconnectInstant, lastOfflineNotificationInstant.get()).isNegative()) {
    // ...
}

image-04.png

SonarLint在嘴上起泡沫,告诉我们减少69的认知复杂度(允许15)。单元测试也变得非常复杂,不得不使用外部Clock。 正确但混乱的实现

我们不能让未来的自己看到这一点,他们会杀了我们!

幸运的是,我们有三个步骤的程序。让我们从1开始。

按功能划分

现在,解开这个烂摊子。我们的第一步是按功能划分较大的部分,考虑解决方案的各个部分以及它们之间的互连方式。

我们可以通过绘制时间线来可视化我们的问题,从设备离线的那一刻开始:

image-13.png

时间表 在时间轴上,我们可以绘制各种阈值:

image-14.png

每次工作运行时,我们都会到达时间表上的某个点。如果我们尚未超过阈值,则无需执行任何操作:

image-15.png

如果确实超过阈值,则应该发送通知并记住发送时间:

image-10.png

下次运行作业时,我们应该考虑已发送的通知。如果我们已经超过了已经通知用户的阈值,那么我们将不执行任何操作:

image-11.png

仅当超过另一个阈值时,我们才能发送另一个通知:

image-12.png

等等等等。

我们如何用代码表达这一点?看来我们需要知道:

  • 我们上次通过哪个threshold (if any)。
  • 我们发送了最后一个通知的threshold (if any))。

基本上就是这样。如果我们知道这些,我们可以确定是否应该发送通知。我们解决方案的这两个核心部分捕获了所有特殊情况和复杂性。

现在,我们需要看看是否可以通过将其分解为可组合的部分来进一步简化解决方案。

将其分解为可组合单元

image-09.png

上面的两个陈述看起来非常熟悉,不是吗?那是因为他们是。它们都定义阈值或什么都不定义。他们都需要类似的输入:

  • The moment the device went offline.
  • The threshold(s).
  • The point in time we are currently evaluating.

现在我们知道了单元的输入和输出,可以定义其签名了。使用fancy java.timeAPI,我们可以这样表达它:

Optional calculateLastPassedThreshold(Instant start, Instant current, Duration[] thresholds);

现在,我们可以使用此功能来获得所需的两个阈值。我说功能了吗?让我们做一个纯函数:声明它为静态,并确保它是确定性的,并且不会产生任何副作用:

static Optional<Duration> calculateLastPassedThreshold(Instant start, Instant current, List<Duration> thresholds) {
    Duration timePassed = Duration.between(start, current);

    if (timePassed.compareTo(thresholds.get(0)) <= 0) {
        return Optional.empty();
    }

    for (int i = 0; i < thresholds.size(); i++) {
        if (timePassed.compareTo(thresholds.get(i)) <= 0) {
            return Optional.of(thresholds.get(i - 1));
        }
    }

    return Optional.of(thresholds.get(thresholds.size() - 1));
}

那里!除了令人叹为观止的纯度之外,它还有另一个优点:可以非常容易地对其进行单元测试。您不需要模拟程序或外部时钟来测试代码的这一部分。

您能感觉到您的大脑刚刚恢复了多少空间吗?在对我们的解决方案的其余部分进行编程时,我们可以相信该功能将按其说明进行操作。

那还剩下什么呢?

现在,我们可以为每个设备确定是否应该发送通知。同样,我们可以隔离程序的这一部分,使其纯净并对其进行彻底的测试:

static boolean shouldSendNotification(Instant jobStart, Instant deviceOffline, Instant lastNotification, List<Duration> thresholds) {
    Optional<Duration> lastPassedThreshold = calculateLastPassedThreshold(deviceOffline, jobStart, thresholds);

    if (!lastPassedThreshold.isPresent()) {
        return false;
    }

    if (lastNotification.isBefore(deviceOffline)) {
        return true;
    }

    Optional<Duration> lastPassedThresholdNotifiedAbout = calculateLastPassedThreshold(deviceOffline, lastNotification, thresholds);

    return !lastPassedThreshold.equals(lastPassedThresholdNotifiedAbout);
}

或者,更简而言之:

static boolean shouldSendNotification(Instant jobStart, Instant deviceOffline, Instant lastNotification, List<Duration> thresholds) {
    Optional<Duration> lastPassedThreshold = calculateLastPassedThreshold(deviceOffline, jobStart, thresholds);

    return lastPassedThreshold.isPresent() && (lastNotification.isBefore(deviceOffline) || !lastPassedThreshold.equals(calculateLastPassedThreshold(deviceOffline, lastNotification, thresholds)));
}

而且,当您知道设备之前没有发送过通知时,可以使用:

static boolean shouldSendNotification(Instant jobStart, Instant deviceOffline, List<Duration> thresholds) {
    return calculateLastPassedThreshold(deviceOffline, jobStart, thresholds).isPresent();
}

现在剩下的唯一事情是每次我们的作业运行时,为每个脱机设备调用这些函数:

public void run() {
    Instant jobStart = Instant.now();

    offlineDevices.entrySet().stream()
            .filter(offlineDevice -> pushNotificationService
                    .getLastOfflineNotificationInstant(offlineDevice.getKey())
                    .map(instant -> shouldSendNotification(jobStart, offlineDevice.getValue(), instant, thresholds))
                    .orElseGet(() -> shouldSendNotification(jobStart, offlineDevice.getValue(), thresholds))
            )
            .forEach(offlineDevice -> pushNotificationService.sendOfflineNotification(offlineDevice.getKey()));
}

我们在这里与iamverysmart -territory接壤。并非每个人都喜欢这种编码风格,并且可以说它比传统风格更难解析。因此,我们至少可以做的是记录我们的单位。

记录我们的解决方案

除了与他人相处融洽之外,描述我们的代码还可以帮助我们捕获最后的错误,或者揭示我们尚未想到的极端情况。

在我们的案例中,文档显然以Javadoc的形式出现,例如:

/**
 * Checks whether a notification should be sent by determining which threshold has been passed last for the
 * calculated amount of time passed between the device going offline and the job running.
 *
 * @param jobStart         The instant the job calling this function was started.
 * @param deviceOffline    The instant the device went offline.
 * @param lastNotification The instant the last notification was sent.
 * @param thresholds       The list of notification thresholds.
 * @return True if the notification should be sent, false if not.
 */
static boolean shouldSendNotification(Instant jobStart, Instant deviceOffline, Instant lastNotification, List<Duration> thresholds) {
    // ...
}

单元测试也可以被视为文档,因为它们可以非常精确地定义被测程序的预期用途。在纯函数的情况下,通常需要测试各种输入的列表,因此参数化测试会派上用场。

在编写测试和文档时,我们将思想从问题转移到解决方案及其用户。通过换个角度思考,我们的代码中的细微问题会突出显示,可以当场解决。

Afterthoughts 很难说服他人甚至您自己这种方法的价值。精心设计功能比精心设计软件具有更明显的商业价值。

当以这种方式处理现有代码时,您可能会浪费大量时间,而没有增加任何可衡量的(短期)价值。每天站起来时尝试解释一下。

另一方面,防止bug的成本比修复bug的成本低,并且代码越干净,添加功能或发现错误所花费的时间就越少,尤其是在一段时间内没有触摸代码的情况下。

沿着这条线的某个地方,天平已倾斜。一方面是圣杯,这是无法实现的完美软件。另一方面,无法维持的意大利面条怪物滑行,按时交货,预算迅速膨胀,并摧毁了团队。

倾听您的直觉,交流您的意图,倾听您的同伴并互相学习:制作出色的软件是一项持续的(团队)工作。


原文链接:http://codingdict.com