Java 函数式编程十一: 重构为函数式风格

过去,Java 程序员只能使用命令式编程风格编写面向对象的代码。如今,他们既可以使用命令式风格,也可以使用函数式风格来编写面向对象的代码。编写代码时有这样的选择很不错。函数式风格的代码并非在所有情况下都是理想的,例如,正如我们在<<Java 函数式编程十: 错误处理>>中所看到的,在处理副作用或异常时就不太合适。但它通过减少不必要的复杂性,使代码更易于阅读和理解,从而胜过了命令式风格。

当我们坐下来编写新代码时,可以运用我们的函数式编程技能。但许多企业应用程序并非全新开发的项目。我们常常会接手一个已经存在多年、由公司里已经离职的人编写的代码库。

世界上有大量的 Java 代码采用的是命令式风格。主要有两个缘由。第一,命令式风格在 Java 编程中曾经是主流方式,而且由于在很长一段时间内这是唯一的选择,大多数程序员对这种风格超级熟悉。其次,尽管从 Java 8 版本开始就可以进行函数式编程了,但我们很自然地会回归到我们熟悉且常常使用的方式,由于它们让人感到亲切。期望我们某天早上醒来就能像前一晚编写命令式风格代码那样轻松地编写函数式风格的代码是不现实的。因此,在过去的几年里,依旧编写了许多新的命令式风格的代码。

代码能正常运行,你可能会想,为什么还要费力将其重构为函数式风格呢?最简单的缘由是减少不必要的复杂性,使代码更易于理解,最重大的是,让代码看起来更明显是正确的。采用函数式风格后,不必要的复杂性被隐藏起来,被封装到了底层的代码库中,我们的代码读起来更像是问题陈述。这减少了语义差异,降低了代码的维护成本。这听起来很棒,但这是否意味着我们必须重构所有现有的代码呢?当然不是。

将所有现有的命令式风格代码都改为函数式风格代码是不切实际的,我当然也不提议这么做。不要为了改变而改变代码。在某些情况下,我们可以而且应该将命令式风格的代码重构为函数式风格的代码。务实一些,而不是教条主义,我们会从中受益。

那些没有其他理由需要修改的代码模块,不值得花时间从命令式风格重构为函数式风格。无论这些代码有多复杂,反正没人会去动它们,所以明智的做法是把时间和精力花在其他地方。

在已经存在了一段时间的应用程序中,当你修改一段代码时 —— 无论是对现有功能进行增强、添加新功能还是修复 bug —— 这都是进行重构的好时机。你正在修改的代码很可能还会继续被修改,因此值得让它变得不那么复杂、更易于阅读且维护成本更低。

无论是在全新的项目还是现有的应用程序中编写新代码时,我们可能会由于熟悉命令式风格而倾向于使用它来编写代码。这完全没问题。我们不是在比赛 —— 不要让任何人,尤其是你自己,由于你用命令式风格编写代码而嘲笑你。我超级赞同“先让代码运行起来,然后尽快让它变得更好”这句话。如果你发现用命令式风格更容易表达你的想法,那就这么做吧。一旦代码能正常工作,并且有了完善的测试,就将新代码重构为函数式风格,并验证测试是否依旧通过。你先实现了逻辑,然后降低了复杂性,这很棒。随着你对函数式风格越来越熟悉,你会发现自己逐渐会先使用函数式风格编写代码,而不是先使用命令式风格编写,然后再进行重构。给自己和同事一些时间来完成这个转变。

你可能已经意识到,用函数式风格编写代码不仅仅是学习一套特定的语法。这是一种范式转变,需要改变我们的思维方式,需要探索、尝试新想法、制作原型,并且要有耐心。将代码从命令式风格重构为函数式风格是进行这种实践的最佳方式之一。

在本文中,我们将把日常遇到的一些常见编程任务的代码从命令式风格重构为函数式风格。完成这些任务的重构步骤将有助于你思考如何用函数式风格进行编程。当你看到每个问题时,尝试编写代码并以函数式风格重新实现解决方案。然后继续阅读,将你的思路与书中概述的细节和最终代码进行比较。

在开始第一个重构任务之前,让我们讨论一下重构中至关重大的一个关键步骤 —— 拥有一套自动化测试。

为重构创建安全网

重构之举,需在确保不影响代码外部表现的基础上,对其内部结构予以优化。我们常常期望能从重构中获取益处,不过,也务必对其中潜藏的风险保持警觉。担忧代码在修改之后出现行为偏差,此乃合乎情理之事。毕竟,代码中存在着隐匿的复杂性,我们或许并未对打算修改的代码形成全面透彻的理解。

无论函数式风格的代码看似多么引人入胜,倘若它无法产生与所替代的命令式风格代码一致的结果,那便毫无价值可言。自动化测试为重构构筑了一道安全防线。一旦我们拥有了能够通过的测试用例,每一次对代码进行修改之后,便能够迅速验证先前正常运行的代码是否依旧以一样的方式运作。

在编写新代码时,无论采用命令式风格还是函数式风格,均需编写测试用例。即便我们从一开始就运用函数式风格编写代码,也定然能够探寻到改善代码设计与质量的途径。测试能够让我们笃定重构不会对现有的功能造成破坏。

遗留代码会带来额外的挑战,因其可能缺乏用于验证自身行为的自动化测试。若条件允许,在重构代码之前,应为其编写自动化单元测试。若无法实现这一点,则需找出涉及计划重构代码的集成测试。倘若这两种选择皆不可行,那就只能依靠手动测试来验证代码的行为。

一旦确定了能够尽可能快速、可靠地提供反馈的测试方式,便可着手开展代码重构工作。

在本文的每一个示例当中,我们都会以一段采用命令式风格编写的任务代码为起点,同时配备一组用于验证代码行为的单元测试。在实践过程中,你需要输入代码以及相应的测试用例,并验证测试是否通过。接着,将代码重构为函数式风格。最后,再次验证测试是否依旧能够通过,以此确认函数式风格的代码能够产生与所替代的命令式风格代码一样的结果。

让我们从第一个重构任务开始吧 —— 我们先从一个相对简单的问题入手,热热身。

重构传统 for 循环

现存的代码库中,for 循环比比皆是。我们借助它们来遍历数据集合,亦用其遍历一系列索引。为了阐明怎样把传统的命令式 for 循环重构为函数式风格的代码,我们不妨来看一个实例,即运用 for 循环计算某个范围内数字的阶乘。

当然,我们第一要编写一个测试,以此为刚刚探讨的重构工作提供可靠的安全保障。

public class FactorialTest {
    Factorial factorial;

    @BeforeEach
    public void init() {
        factorial = new Factorial();
    }

    @Test
    public void computeFactorial() {
        assertAll(
                () -> assertEquals(BigInteger.ONE, factorial.compute(1)),
                () -> assertEquals(BigInteger.TWO, factorial.compute(2)),
                () -> assertEquals(BigInteger.valueOf(6), factorial.compute(3)),
                () -> assertEquals(BigInteger.valueOf(120), factorial.compute(5))
        );
    }
}

这个测试用于验证尚未看到的 Factorial 类的 compute 方法是否能为给定的上限值返回正确的阶乘结果。目前,让我们看看 Factorial 类,其中 compute 方法是用命令式风格实现的:

public class Factorial {
    public BigInteger compute(long upTo) {
        BigInteger result = BigInteger.ONE;
        for (int i = 1; i <= upTo; i++) {
            result = result.multiply(BigInteger.valueOf(i));
        }
        return result;
    }
}

和传统的 for 循环用法一样,我们遍历从 1 到 upTo 变量值的范围。你的任务是:暂停阅读,运行测试并验证其是否通过。然后将 compute 方法重构为函数式风格。再次验证测试是否通过,然后继续阅读。在接下来的小节中,我们将这些步骤称为: 暂停、重构、继续。

目前你回来了,让我们将你采取的步骤与我这里提议的步骤进行比较。当尝试从命令式风格重构为函数式风格时,并不总是清楚从哪里开始以及如何进行。一种有用的技巧是先进行声明式思考,然后进行函数式编程。

正如我们之前讨论的,命令式风格是我们告知代码要做什么以及如何去做。而在声明式风格中,我们关注的是要做什么,将如何做的细节委托给底层库。

函数式风格 = 声明式风格 + 高阶函数的使用

函数式编程风格的一个关键优势在于其声明式的特性。在采用函数式风格之前,花些时间进行声明式思考,这有助于你确定与命令式风格不同的代码实现方式中的一些关键要素。不要关注每个步骤是如何执行的,而是将问题看作是一系列将数据从给定输入转换为所需输出的高级步骤。让我们深入研究手头的问题,看看这种方法如何发挥作用。

为了计算阶乘,我们处理从 1 到给定数字作为上限的一系列值。对于该范围内的每个数字,我们需要其 BigInteger 表明形式。最后,我们累加这些 BigInteger 值的乘积,这是一个归约操作。手头的问题可以表明为一系列步骤或管道:范围 ⇒ 映射 ⇒ 归约。我们可以重构代码来实现这些想法。

第一步,我们需要获取从 1 到给定数字的一系列长整型值。快速查阅 JDK 可知,LongStream 接口有一个名为 rangeClosed 的静态方法,它会生成一个 LongStream 来遍历指定范围内的值。一旦我们有了 LongStream,就想将范围内的值转换为 BigInteger 表明形式的值。我们不能使用 map 方法,由于它期望将 LongStream 转换为另一个 LongStream,而不是 Stream<BigInteger>,所以我们将使用 mapToObj 方法。最后一步,我们可以使用归约操作。

让我们将这些步骤组合成代码:

public BigInteger compute(long upTo) {
    return LongStream.rangeClosed(1, upTo)
           .mapToObj(BigInteger::valueOf)
           .reduce(BigInteger.ONE, BigInteger::multiply);
}

除了使用 Stream API,我们还使用了方法引用而不是创建 Lambda 表达式,这使得代码更加简洁和富有表现力。没有显式的可变变量,更少的冗余代码,更具表现力,这些都是我们从函数式风格中可以期待的好处。

接下来,让我们看一个稍微复杂一些的问题。

重构更复杂的循环

传统的 for 循环允许我们以不同的步长值对范围进行递增或递减操作。让我们来看一下 LeapYears 类中的 countFrom1900 方法,该方法用于计算从 1900 年到给定年份之间的闰年数量。

我们先从测试开始:

public class LeapYearsTest {
    LeapYears leapYears;

    @BeforeEach
    public void init() {
        leapYears = new LeapYears();
    }

    @Test
    public void countFrom1900() {
        assertAll(
                () -> assertEquals(25, leapYears.countFrom1900(2000)),
                () -> assertEquals(27, leapYears.countFrom1900(2010)),
                () -> assertEquals(31, leapYears.countFrom1900(2025)),
                () -> assertEquals(49, leapYears.countFrom1900(2100)),
                () -> assertEquals(0, leapYears.countFrom1900(1800))
        );
    }
}

这些测试用于验证 countFrom1900 方法对于作为参数传入的不同年份是否能返回正确的结果。

目前,让我们看看我们想要重构的命令式风格代码:

public class LeapYears {
    public int countFrom1900(int upTo) {
        int numberOfLeapYears = 0;
        for (int i = 1900; i <= upTo; i += 4) {
            if (Year.isLeap(i)) {
                numberOfLeapYears++;
            }
        }
        return numberOfLeapYears;
    }
}

由于闰年最多每四年出现一次,所以这个 for 循环以 4 为步长递增索引。然后,它使用 JDK 库中的 isLeap 方法来检查某一年是否为闰年,并相应地增加计数。

暂停、重构、继续。

我们需要为以任意步长递增的 for 循环找到一个函数式风格的等效实现。让我们用函数式的视角更仔细地看看这个 for 语句:

for (int i = 1900; i <= upTo; i += 4) {

可以理解为

for(初始值, 检查上限的谓词, 步长函数)

这个 for 循环从一个初始值开始,它可以使用一个 Predicate 来检查循环是否应该继续,以及一个函数来递增索引值。目前我们需要在 JDK 中寻找这样的函数。结果发现,IntStream 正好有我们需要的 —— iterate 函数。让我们再次思考一下,使用 iterate 函数后,这个 for 循环在函数式风格下会是什么样子:

iterate(1900, year -> year <= upto, year -> year + 4)

这个循环可以从 1900 开始。第二个参数是一个 Predicate,它可以检查在迭代过程中年份是否在 upTo 的值的范围内。第三个参数是一个 Function,它可以将年份的值按所需的步长 4 进行递增。iterate 函数将生成一个数字流,从初始值开始,以给定的步长递增,直到 Predicate 检查的值为止。

我们可以使用这个流来重构命令式风格的代码:

public int countFrom1900(int upTo) {
    return (int) IntStream.iterate(
            1900, year -> year <= upTo, year -> year + 4)
           .filter(Year::isLeap)
           .count();
}

一旦我们找到了 for 循环的函数式等效实现,其余的步骤就相当简单了。命令式风格中更复杂的循环变成了一个优雅的函数式管道。接下来,我们将把一个无界的命令式风格循环重构为另一个优雅的函数式风格迭代。

重构无界循环

传统的 for 循环尽管复杂,但却超级通用。可以不指定上限是一项很棒的功能,它能让我们更轻松地执行循环,直到满足某个条件为止。让我们来看一下 countFrom1900 方法的一个变体,它就利用了这一功能。当然,还是要先进行测试。

public class LeapYearsUnboundedTest {
    LeapYearsUnbounded leapYearsUnbounded;

    @BeforeEach
    public void init() {
        leapYearsUnbounded = new LeapYearsUnbounded();
    }

    @Test
    public void count() {
        assertAll(
                () -> assertEquals(25,
                        leapYearsUnbounded.countFrom1900(year -> year <= 2000)),
                () -> assertEquals(27,
                        leapYearsUnbounded.countFrom1900(year -> year <= 2010)),
                () -> assertEquals(31,
                        leapYearsUnbounded.countFrom1900(year -> year <= 2025)),
                () -> assertEquals(49,
                        leapYearsUnbounded.countFrom1900(year -> year <= 2100)),
                () -> assertEquals(0,
                        leapYearsUnbounded.countFrom1900(year -> year <= 1800))
        );
    }
}

这些测试没有向 countFrom1900 方法传递年份上限,而是传递了一个 Lambda 表达式,用于检查给定年份是否达到了上限。让我们看看作为重构候选的命令式风格版本的代码:

interface Continue {
    boolean check(int year);
}

public class LeapYearsUnbounded {
    public int countFrom1900(Continue shouldContinue) {
        int numberOfLeapYears = 0;
        for (int i = 1900; ; i += 4) {
            if (!shouldContinue.check(i)) {
                break;
            }
            if (Year.isLeap(i)) {
                numberOfLeapYears++;
            }
        }
        return numberOfLeapYears;
    }
}

该方法使用了一个名为 Continue 的接口,其中有一个 check 方法,countFrom1900 方法接收一个实现了该接口的实例作为参数。在方法内部,循环是无界的,它使用传入的 Continue 实例来确定循环是应该继续还是跳出。

暂停、重构、继续。

作为第一个重构机会,我们可以用 JDK 中的函数式接口 Predicate 来替换自定义的 Continue 接口。

无界 for 循环的函数式风格等效实现不需要 Predicate,由于它不必检查上限。IntStream 的 iterate 方法的开发者预料到了这种使用场景,提供了该方法的一个重载版本,该版本只接受两个参数,而不使用 Predicate,而不是原本的三个参数。

命令式风格的 for 循环使用 break 语句来退出循环。在函数式编程中没有 break,但我们有 takeWhile 方法 —— 参见“终止迭代”。我们可以通过将 iterate 方法的调用与 takeWhile 方法的调用链式组合,来重构这个命令式风格的无界循环,如下所示:

public int countFrom1900(Predicate<Integer> shouldContinue) {
    return (int) IntStream.iterate(1900, year -> year + 4)
           .takeWhile(shouldContinue::test)
           .filter(Year::isLeap)
           .count();
}

我们少用了一个接口,方法变得简洁且富有表现力。重构成功。在下一个任务中,我们将把使用 for – each 循环的命令式风格代码重构为函数式风格。

重构 for – each 循环

作为 Java 程序员,我们已经喜爱并广泛使用所谓的 for – each 语法,其形式为 for(Type variable: iterable)。当我们不需要索引,只关心集合中的元素时,它比传统的 for 循环简单得多。但在两种形式的 for 循环中使用 break 和 continue 也很常见。结果是代码变得冗长,从长远来看维护起来很费力。让我们尝试将使用 for – each 循环的代码重构为函数式风格。

按照我们的惯例,先从测试开始:

public class AgencyTest {
    Agency agency;

    @BeforeEach
    public void init() {
        agency = new Agency();
    }

    @Test
    public void isChaperoneRequired() {
        assertAll(
                () -> assertTrue(agency.isChaperoneRequired(
                        Set.of(new Person("Jake", 12)))),
                () -> assertTrue(agency.isChaperoneRequired(
                        Set.of(new Person("Jake", 12), new Person("Pam", 14)))),
                () -> assertTrue(agency.isChaperoneRequired(
                        Set.of(new Person("Shiv", 8),
                                new Person("Sam", 9), new Person("Jill", 11)))),
                () -> assertFalse(agency.isChaperoneRequired(
                        Set.of(new Person("Jake", 12), new Person("Pam", 18)))),
                () -> assertFalse(agency.isChaperoneRequired(Set.of()))
        );
    }
}

这个测试使用了 Person 类,它可以实现为一个记录类(在较旧版本的 Java 中是一个普通类)。

refactoring/fpij/Person.java
public record Person(String name, int age) {}

一个机构负责决定一群人出行时是否需要监护人。做出这个决定的逻辑在 Agency 类的 isChaperoneRequired 方法中:

public class Agency {
    public boolean isChaperoneRequired(Set<Person> people) {
        boolean required = true;
        if (people.size() == 0) {
            required = false;
        } else {
            for (var person : people) {
                if (person.age() >= 18) {
                    required = false;
                    break;
                }
            }
        }
        return required;
    }
}

这个方法的功能相当简单,但代码中存在许多冗余。如果群体中有任何一个人年满 18 岁或者群体为空,那么就不需要监护人。这段代码需要进行一些重构,以减少冗余并消除可变性。

暂停、重构、继续。

我们知道,不使用 for – each 循环,我们可以使用 stream 方法来遍历集合。给定一群人,我们想知道列表中是否有人年满 18 岁,以决定是否需要监护人。我们可以为此使用 Stream 的 noneMatch 方法,如下所示:

public boolean isChaperoneRequired(Set<Person> people) {
    return people.size() > 0 &&
            people.stream()
                   .noneMatch(person -> person.age() >= 18);
}

这段代码的简洁性很难被超越。在我们目前看到的重构示例中,我们在从命令式风格转换为函数式风格时,成功地保持了逻辑不变。但并非总是如此,有时我们可能需要重新设计算法或逻辑,接下来我们会看到这样的情况。

重构以重新设计逻辑

有时在重构为函数式风格时,我们可能需要退后一步,重新思考逻辑或算法,而不是仅仅尝试从命令式风格映射到函数式风格。如果在这种情况下不采取其他方法,我们要么在创建函数式风格代码时遇到问题,要么重构后的版本可能很复杂,让我们期待更好的解决方案。在本节中,我们将看一个这样的问题 —— 这是一位对解决此问题感兴趣,但在创建函数式等效代码时遇到困难的开发者提出的。

给定一个字符串,我们想找到在字符串中其他位置首次重复出现的字母。例如,在字符串 hellothere 中,第一个重复的字母是 h,尽管字母 l 和 e 也重复出现了。在处理这类问题时,特别是当我们希望重构代码时,测试是澄清细节和解决任何歧义的最佳方法之一。以下是针对这个问题的一些测试:

public class FirstRepeatedLetterTest {
    FirstRepeatedLetter firstRepeatedLetter;

    @BeforeEach
    public void init() {
        firstRepeatedLetter = new FirstRepeatedLetter();
    }

    @Test
    public void findFirstRepeating() {
        assertAll(
                () -> assertEquals('l', firstRepeatedLetter.findIn("hello")),
                () -> assertEquals('h', firstRepeatedLetter.findIn("hellothere")),
                () -> assertEquals('a', firstRepeatedLetter.findIn("magicalguru")),
                () -> assertEquals('', firstRepeatedLetter.findIn("once")),
                () -> assertEquals('', firstRepeatedLetter.findIn(""))
        );
    }
}

让我们看看能通过上述测试的命令式风格代码:

public class FirstRepeatedLetter {
    public char findIn(String word) {
        char[] letters = word.toCharArray();
        for (char candidate : letters) {
            int count = 0;
            for (char letter : letters) {
                if (candidate == letter) {
                    count++;
                }
            }
            if (count > 1) {
                return candidate;
            }
        }
        return '';
    }
}

我们在外部循环中遍历给定单词中的字母。在内部循环中,我们检查外部循环中选取的字母在给定单词的字母中出现的次数。一旦我们发现某个字母重复出现,就将其作为结果返回。这段代码冗长、冗余且难以阅读,但一旦我们理解了其中隐藏的逻辑,就会发现它相当直接。当然,我们希望代码更易于阅读和理解,所以我们想将其重构为函数式风格。让我们试试看。

暂停、重构、继续。

如果我们直接尝试将这两个循环转换为函数式风格的等效代码,结果最多只能是一团糟。相反,我们可以退后一步,重新思考解决这个问题的方法。在此过程中,我们可以利用函数式管道的惰性求值特性。

如果一个字母在给定单词中最后一次出现的位置大于其第一次出现的位置,那么这个字母就是重复的。如果它没有重复,其最后一次出现的位置将与第一次出现的位置一样。从给定的单词中,我们可以过滤出重复的字母。乍一想,这似乎效率很低。既然我们只需要第一个重复的字母,为什么要处理所有字母呢?但由于惰性求值(参见”
Java 函数式编程七: Lazy”),一旦找到第一个重复的字母,计算就可以停止,后续的字母就不必处理了。我们可以通过使用 findFirst 方法来实现这一点。

让我们应用这些想法来重构代码。

public char findIn(String word) {
    return Stream.of(word.split(""))
           .filter(letter -> word.lastIndexOf(letter) > word.indexOf(letter))
           .findFirst()
           .map(letter -> letter.charAt(0))
           .orElse('');
}

代码再次变得富有表现力、简洁且易于阅读。如果你愿意,可以将 Lambda 表达式提取到一个单独的名为 isDuplicated 的函数中,使代码更易读一些。

Stream API 可以轻松用于处理内存中的数据集合。接下来,我们将看看如何处理文件中的数据。

重构文件处理

从文件中读取数据是编程中常见的操作,我信任你已经做过无数次了。但过去执行该操作的代码相当冗长和杂乱。让我们看一个函数,它读取文件内容并统计其中某个单词的出现次数。然后我们将其重构为函数式风格。

我们先进行一组小测试,以验证 countInFile 函数是否按预期工作:

public class WordCountTest {
    WordCount wordCount;

    @BeforeEach
    public void init() {
        wordCount = new WordCount();
    }

    @Test
    public void count() {
        assertAll(
                () -> assertEquals(2,
                        wordCount.countInFile("public", "fpij/WordCount.java")),
                () -> assertEquals(1,
                        wordCount.countInFile("package", "fpij/WordCount.java"))
        );
    }
}

这些测试表明,被测试的方法接受一个搜索词和要搜索的文件路径。以下是亟待重构的命令式风格代码:

public class WordCount {
    public long countInFile(
            String searchWord, String filePath) throws IOException {
        long count = 0;
        BufferedReader bufferedReader =
                new BufferedReader(new FileReader(filePath));
        String line = null;
        while ((line = bufferedReader.readLine()) != null) {
            String[] words = line.split(" ");
            for (String word : words) {
                if (word.equals(searchWord)) {
                    count++;
                }
            }
        }
        return count;
    }
}

该函数通过将读取的内容与 null 进行比较来检查文件中是否还有更多行可读 —— 哎呀,这实在不太美观。我们需要更简便的方法来读取和处理文件内容,幸好 JDK 有新的函数为此引入了函数式风格的魅力。

暂停、重构、继续。

给定文件内容,我们要查找其中某个单词的出现次数。我们可以使用 filter 操作来检查每个单词是否是我们要找的,然后使用 reduce 操作来统计出现次数。但要执行这些步骤,我们需要一个文件内容的流。JDK 的 java.nio.file 包中的 Files 类有适合的函数。lines 方法返回一个 Stream<String>,我们可以应用喜爱的 Stream 操作来处理文件内容。让我们使用该函数重构 countInFile 方法:

public long countInFile(
        String searchWord, String filePath) throws IOException {
    return Files.lines(Paths.get(filePath))
           .flatMap(line -> Stream.of(line.split(" ")))
           .filter(word -> word.equals(searchWord))
           .count();

lines 方法创建一个内部迭代器,一次处理文件内容的一行。但我们要处理文件中的单词,而不只是行。我们可以调用 split 将一行拆分为单词,以便对单词进行进一步处理。由于将一行拆分为单词是一对多的映射,我们必须使用 flatMap 而不是 map 来将数据从行流转换为单词流 —— 参见“何时使用 map 与 flatMap”。一旦得到单词流,我们就可以进行过滤,最后进行计数。

除了代码简洁且富有表现力之外,还有一个额外的好处,我们不必处理 null,这本身就是一个很大的胜利。

接下来,我们将处理一个数据存储在 Map 中的示例。

重构数据分组操作

根据某些标准对数据进行分组是业务应用程序中的常见操作。我们可能想根据工作地点对员工进行分组,根据业务部门对项目进行分组,根据最新收入对产品进行分组,等等。在命令式风格中,为分组创建一个 Map 并为其键添加值可能会变得冗长,还可能涉及一些低效问题。你会看到执行此类处理的命令式风格代码与其等效的函数式风格代码之间存在鲜明对比。

为了深入了解重构执行分组操作的代码,我们将使用一个假设的游戏得分集合,以玩家姓名和得分的键值对形式提供。让我们从一些测试开始,这些测试验证 namesForScores 函数是否根据得分对数据进行分组,并为每个得分创建一个关联的姓名列表。

public class ScoresTest {
    Scores scores;

    @BeforeEach
    public void init() {
        scores = new Scores();
    }

    @Test
    public void namesForScores() {
        assertAll(
                () -> assertEquals(Map.of(), scores.namesForScores(Map.of())),
                () -> assertEquals(
                        Map.of(1, Set.of("Jill")), scores.namesForScores(Map.of("Jill", 1))),
                () -> assertEquals(
                        Map.of(1, Set.of("Jill"), 2, Set.of("Paul")),
                        scores.namesForScores(Map.of("Jill", 1, "Paul", 2))),
                () -> assertEquals(
                        Map.of(1, Set.of("Jill", "Kate"), 2, Set.of("Paul")),
                        scores.namesForScores(Map.of("Jill", 1, "Paul", 2, "Kate", 1)))
        );
    }
}

为了根据得分对数据进行分组,我们必须遍历给定 Map 的键集。对于每个玩家,我们检查他们的得分值是否已经在目标 Map 中。如果是,我们将姓名添加到现有键的值集合中。如果得分还没有作为键存在,我们就创建一个包含玩家姓名的新集合,并将该集合作为新键的值添加到目标 Map 中。以下是执行这些繁琐操作的代码:

public class Scores {
    public Map<Integer, Set<String>> namesForScores(
            Map<String, Integer> scores) {
        Map<Integer, Set<String>> namesForScores = new HashMap<>();
        for (String name : scores.keySet()) {
            int score = scores.get(name);
            Set<String> names = new HashSet<>();
            if (namesForScores.containsKey(score)) {
                names = namesForScores.get(score);
            }
            names.add(name);
            namesForScores.put(score, names);
        }
        return namesForScores;
    }
}

大量的临时变量、显式的可变操作、底层操作,等等 —— 真让人不寒而栗。

暂停、重构、继续。

namesForScores 方法的整个主体可以重构为实际上只有三行代码:

public Map<Integer, Set<String>> namesForScores(
        Map<String, Integer> scores) {
    return scores.keySet()
           .stream()
           .collect(groupingBy(scores::get, toSet()));
}

我们使用 stream 方法遍历键集,并要求根据每个姓名对应的得分值对值进行分组。然后,我们通过将 toSet 返回的 Collector 提供给 groupingBy 函数,要求将姓名放入一个集合中。这段代码强劲得惊人。

通过每个示例,我们处理的复杂度逐渐增加,每个示例都为我们在重构为函数式风格时提供了一些新的思路。下一个示例,也是这个重构系列任务中的最后一个,增加了一些不错的复杂度,并带来了一套强劲的解决方案 —— 让我们来看看。

重构嵌套循环

本节中的命令式风格代码是一位对应用函数式编程感兴趣,并好奇如何将其重构为函数式风格的开发者发给我的。这段代码用于生成满足 a2+b2=c2 条件的正整数勾股数三元组 (a,b,c)。

我们先进行一些测试,以验证 PythagoreanTriples 类的 compute 方法的结果:

public class PythagoreanTriplesTest {
    PythagoreanTriples pythagoreanTriples;

    @BeforeEach
    public void init() {
        pythagoreanTriples = new PythagoreanTriples();
    }

    @Test
    public void compute() {
        assertAll(
                () -> assertEquals(List.of(), pythagoreanTriples.compute(0)),
                () -> assertEquals(List.of(triple(3, 4, 5)),
                        pythagoreanTriples.compute(1)),
                () -> assertEquals(
                        List.of(triple(3, 4, 5), triple(8, 6, 10), triple(5, 12, 13)),
                        pythagoreanTriples.compute(3)),
                () -> assertEquals(
                        List.of(triple(3, 4, 5), triple(8, 6, 10),
                                triple(5, 12, 13), triple(15, 8, 17),
                                triple(12, 16, 20)),
                        pythagoreanTriples.compute(5))
        );
    }
}

接下来是使用命令式风格实现的 compute 方法:

record Triple(int a, int b, int c) {
    public static Triple triple(int a, int b, int c) {
        return new Triple(a, b, c);
    }

    public String toString() {
        return String.format("%d %d %d", a, b, c);
    }
}

public class PythagoreanTriples {
    public Triple getTripleEuclidsWay(int m, int n) {
        int a = m * m - n * n;
        int b = 2 * m * n;
        int c = m * m + n * n;
        return triple(a, b, c);
    }

    public List<Triple> compute(int numberOfValues) {
        if (numberOfValues == 0) {
            return List.of();
        }
        List<Triple> triples = new ArrayList<>();
        int count = 1;
        for (int m = 2; ; m++) {
            for (int n = 1; n < m; n++) {
                triples.add(getTripleEuclidsWay(m, n));
                count++;
                if (count > numberOfValues)
                    break;
            }
            if (count > numberOfValues)
                break;
        }
        return triples;
    }
}

我们将勾股数三元组的值存储在一个名为 Triple 的 Java 记录类表明的元组中。该记录类提供了一个静态方法 triple,以便使用 triple(…) 而不是 new Triple(…) 来创建实例,使代码更流畅一些。

getTripleEuclidsWay 方法使用欧几里得算法为给定的正整数 m 和 n(其中 m>n)创建一个勾股数三元组。

compute 方法接受一个参数,表明我们期望创建的三元组的数量。然后它从 2 开始迭代 m 的值。对于从 1 到 m−1 的 n 值,它使用 getTripleEuclidsWay 方法计算所需数量的三元组。

除了代码冗长之外,真正糟糕的部分是有两处检查,以查看是否已经计算出了所需数量的值。

暂停、重构、继续。

对于每个 m 的值,我们会创建多个三元组的值。你清楚了,这是一个一对多的问题,从我们在“何时使用 map 与 flatMap”中的讨论可知,你知道这是一个适合使用 flatMap 解决的问题。让我们使用它将代码重构为函数式风格:

public List<Triple> compute(int numberOfValues) {
    return Stream.iterate(2, e -> e + 1)
           .flatMap(m -> IntStream.range(1, m)
                   .mapToObj(n -> getTripleEuclidsWay(m, n)))
           .limit(numberOfValues)
           .toList();
}

太棒了。

我们创建一个从 2 开始的 m 的无界/无限流。对于每个 m 的值,我们创建一系列 m−1 个三元组。最后,我们要求流将值的数量限制为参数 numberOfValues 中指定的所需值,并将这些值封装到一个列表中。

这段代码最大的挑战是识别它是一个 flatMap 问题。一旦我们做到了这一点,其余的就相对容易实现了。

说到识别,让我们讨论一下在重构实际代码时可以依靠的一些常见模式。

实际项目中的代码重构

本文中的示例或许能让你有信心对正在开发的应用程序中的代码进行重构。不过,在实际工作中,我们要处理的代码很少能成为易于重构的理想对象。重构代码的过程可能令人望而生畏,有时甚至会让人打退堂鼓。这些挫折可能会让我们感到绝望,甚至想放弃。但请记住,重构并非要一蹴而就。

大规模的重构可能会导致彻底的失败。相反,要逐步进行重构。你可以先使用 Stream、IntStream 等内部迭代器将命令式循环转换为函数式迭代。if 条件语句可以重构为 filter() 方法,循环内的操作可以重构为 map() 方法。这样的改动有助于降低代码的复杂度,提高代码的可读性,让代码更趋近于函数式风格。即便代码可能还未转变为最优雅、最纯粹的函数式风格,这些改动也依然是有价值的。

在将代码重构为函数式风格时,要留意一些常见的重构模式。当你了解了可以映射的常见模式后,就能迅速找到合适的函数来使用。以下表格列出了一些常见命令式风格代码结构对应的函数式风格替代方案。

命令式风格

函数式替代方案

常规 for 循环

IntStream、LongStream 等的 range() 或 rangeClosed() 方法

超级规 for 循环

Stream、IntStream 等的 iterate() 方法

循环中的 break

takeWhile() 方法

for – each 循环

Stream 的 of() 方法或集合的 stream() 方法

嵌套循环

Stream 的 flatMap() 方法

带有 continue 的 if 块

Stream 的 filter() 方法

数据累加

reduce()、collect() 方法,或像 sum()、average() 等合适的专用归约方法

获取必定数量的匹配值

filter() 与 limit() 方法结合使用

获取一个匹配值

filter() 与 findFirst() 方法结合使用,可选择性使用 skip() 或 dropWhile() 方法

具有布尔结果的短路循环

anyMatch()、allMatch()、noneMatch() 方法

重构代码时,可以将此表作为参考。在重构过程中,如果你发现代码中出现了其他类似的模式,把它们记录下来并与团队分享,让大家的重构之路更加顺畅。

我们已经对许多常见任务进行了重构。每个问题都让你有机会更深入地探索如何使用函数式风格编写代码。休憩一下,回顾和思考这些示例,然后再进入下一篇的学习。

总结

我们研究了多个用命令式风格编写的示例,并将它们重构为函数式风格。重构的第一步是进行声明式思考,然后寻找能将一系列转换操作实现为函数式风格的函数。一旦掌握了具体步骤,就要在 JDK 中寻找可以委托这些步骤的函数。有时,在将代码重构为函数式风格之前,我们需要退后一步,重新思考算法。通过不断练习,你会越来越熟练、越来越得心应手,从而逐步降低遗留代码中不必要的复杂度。

© 版权声明

相关文章

暂无评论

您必须登录才能参与评论!
立即登录
none
暂无评论...