6.7函数式编程
《奔跑吧,程序员》章节:6.7函数式编程,宠文网网友提供全文无弹窗免费在线阅读。!
遵循单一职责原则会使设计出现许多短小的、简单的、独立的函数,每个函数都容易阅读、维护和测试。而且,我们还可以把这些函数中的几个组合在一起,创建具有更复杂行为的函数,这就是函数式编程的基本原理:使用函数和函数的组合作为应用程序的构建块。其中的关键就是用一种安全且容易组合的方式去设计函数。
函数式编程可以说是一个庞大的主题,Java并不是实现函数式编程的理想语言,所以这一节只会简要介绍函数式编程背后的基本理念,并阐述如何用这些理念得到更加整洁的代码。我将要介绍的基本概念有不可变数据、高阶函数和纯函数。
6.7.1 不可变数据
看看下面的代码:
public class Groceries { public List shoppingList = new ArrayList<>(); public void fillShoppingList() { shoppingList.add("milk"); shoppingList.add("eggs"); shoppingList.add("bread"); if (!isOnDiet()) { addCandy(shoppingList); } if (isXmas()) { addXmasFoods(shoppingList); } } }
如果我们调用fillShoppingList函数,shoppingList字段中存放的值是什么?开始的时候,shoppingList字段是空的,之后的值是 ["milk", "eggs", "bread"]。接下来就不是很明显了,addCandy和addXmasFoods方法获得了shoppingList的引用,所以必须阅读这些函数中的代码才能了解它们对shoppingList做了什么。实际上,由于shoppingList是Groceries类中的一个字段,该类中的任何方法都可以对它进行修改,所以我们还必须阅读isOnDiet和isXmas中的全部代码。而且,因为shoppingList是一个公共字段,可以被任何访问Groceries类的对象修改,这就意味着,除非把整个代码库都爬一遍,否则是无法确定shoppingList中的值的。如果在寻找的过程中,还发现Groceries类被用在了多线程环境中,我们根本就没办法知道shoppingList中存放的究竟是什么,因为它的值取决于线程执行的不确定顺序。
换句话说,即便在这样的一小段代码中,我们也很难推导出shoppingList的值,因为它是可变变量。可变变量是指向内存位置的指针,该位置也许会在不同的时间存入不同的值。一旦考虑时间因素,问题就变得很困难了,我们必须在阅读全部代码的时候,随时记着shoppingList的状态,要兼顾考虑所有可能的时间轴才能确定出它的值。随着可变变量应用范围的扩大,随着并发性的引入,可能的时间轴的数量会以指数形式增加,代码的跟踪就变成不可能了。
更好的方法是使用不可变变量。不可变变量只不过是固定值的一个标识符,永远不会有变化。在大多数函数式编程语言(比如Haskell)中,默认使用的是不可变变量。只要把变量名和值关联起来,它就永远不会改变。例如,如果把x变量的值设置为5,后面又尝试将其修改为6,就会得到一个编译错误:
x = 5 x = 6 -- 编译错误!
在非函数式编程语言中,可变性是默认的,但是通常也提供了将变量标记为不可变的途径。举例来说,在Java中,可以把一个变量声明为final,这样只要给它一个值,它就永远不会改变:
final int x = 5; x = 6; // 编译错误!
如果我们用的是对象而不是原始类型,最好的实践就是将对象中的所有字段设置为不可变:
public class Person { private final String name; private final int age; public Person(String name, int age) { this.name = name; this.age = age; } public String getName() { return name; } public int getAge() { return age; } public Person withName(String newName) { return new Person(newName, age); } public Person withAge(int newAge) { return new Person(name, newAge); } }
注意Person类中的所有字段都被声明为final。另外,虽然代码中有常见的getter方法,但没有setter方法,而是用withX方法返回Person类的新实例。你很可能在以前已经多次用过这样的不可变类,例如在Java中,String类就是不可变的。我们对不可变变量唯一可以做的就是用它们去计算产生新的值:
newValue = someComputation(oldValue);
在String类中,所有看似对String进行修改的方法实际上返回的都是一个新的String:
String str1 = "Hello, World!"; String str2 = str1.replaceAll("l", ""); // str1仍然是"Hello, World!" // str2是"Heo, Word!"
大多数语言对于常用的数据结构也提供了不可变的实现。举个例子,对于Java来说,Google Guava库提供了Set、Map和List的不可变版本:
List shoppingList = ImmutableList.of("milk", "eggs", "bread");
虽然有些问题只能通过可变变量解决,但在编写大部分代码时,仍然可以也应该使用不可变变量。一般的策略是从一个初始值开始,不直接修改它,而是一步一步地将其转换为新的中间值,直至得到某个需要的结果:
originalValue = getOriginalValue(); intermediateValue1 = computation1(originalValue); intermediateValue2 = computation2(intermediateValue1); intermediateValue3 = computation3(intermediateValue2); desiredResult = finalComputation(intermediateValue1, intermediateValue2, intermediateValue3);
注意在上述模式中,我们从没有修改任何旧的值,而是不断地从每一次计算中生成新的中间值。可以用这种策略让Groceries类的推导变得更加容易:
public List buildShoppingList() { List basics = ImmutableList.of("milk", "eggs", "bread"); List candy = !isOnDiet() ? getCandy() : emptyList(); List xmas = isXmas() ? getXmasFoods() : emptyList(); return new ImmutableList.Builder() .addAll(basics) .addAll(candy) .addAll(xmas) .build(); }
其思路就是将每一次的食物“计算”结果存放到永远不会改变的本地的中间List对象中。最后,把所有的List连接成一个新的List并通过函数返回。由于每一个中间List对象都有一个名称,所以代码的逻辑就会变得更容易理解。并且因为一切对象均是不可变的,buildShoppingList函数的代码逻辑现在完全是本地的,没有其他的函数、类或者线程会对结果有任何的影响。有了不可变数据,就不再需要费心考虑时间轴的问题了。
6.7.2 高阶函数
我们可以很轻松地把buildShoppingList()方法中的所有变量改成不可变的,因为已经提前知道要执行的“计算”的次数,所以可以把每一次计算结果都赋给一个命名的中间不可变变量,最后再把它们全部连接起来。但是如果计算执行的次数是不确定的又要怎么办呢?举例来说,在BookParser代码的parseBooksFromCsv方法中,记录的数量取决于CSV文件的内容:
public List parseBooksFromCsvFile(File inputCsv) throws IOException { List records = CSVFormat .DEFAULT .withHeader(TITLE.name(), AUTHOR.name(), PAGES.name(), CATEGORY.name()) .parse(new FileReader(inputCsv)) .getRecords(); List books = new ArrayList<>(); for (CSVRecord record : records) { books.add(parseBookFromCsvRecord(record)); } return books; }
既然必须进行的“计算”(即调用parseBookFromCsvRecord)次数是无法提前预知的,那么如何才能在没有可变变量的情况下构造出Book对象的List呢?一种解决的办法就是使用高阶函数,这是一种能够把其他函数作为参数的函数。
Java增加了对高阶函数的支持,比如map、filter和reduce,在Java语言的第8个版本中,它已经成为了Stream API的一部分。通过比较把Integer类型的List中的所有偶数对象相乘的不同实现方法,可以看出它的实际应用。下面是命令式的解决方案:
List numbers = Lists.newArrayList(1, 2, 3, 4, 5); int product = 1; for (int i = 0; i < numbers.size(); i++) { int number = numbers.get(i); if (number % 2 == 0) { product = product * number; } } // product的值现在是8
命令式的解决方案要求我们必须关注一些底层的、容易出错的细节,比如迭代、列表下标,还要维护一个可变变量去计算product的值。下面是同一个问题的函数式解决方案:
List numbers = ImmutableList.of(1, 2, 3, 4, 5); final int product = numbers .stream() .filter(number -> number % 2 == 0) .reduce((a, b) -> a * b) .orElse(1); // product的值现在是8
函数式解决方案让我们关注一些高级的细节,比如如何找出偶数、如何让两个数字相乘,完全不需要维护任何的可变变量。我们可以使用高阶函数去掉parseBooksFromCsv函数中的可变因素:
public List parseBooksFromCsvFile(File inputCsv) throws IOException { List records = CSVFormat .DEFAULT .withHeader(TITLE.name(), AUTHOR.name(), PAGES.name(), CATEGORY.name()) .parse(new FileReader(inputCsv)) .getRecords(); return records .stream() .map(this::parseBookFromCsvRecord) .collect(Collectors.toList()); }
6.7.3 纯函数
使用不可变数据和高阶函数可使代码更容易理解和维护。但是想要利用函数式编程的最大优势,还需要使用纯函数。纯函数满足以下条件。
· 该函数是幂等的:给定相同的输入参数,函数总是返回精确的相同结果。
· 该函数没有副作用:函数不以任何方式依赖或修改外部世界的状态。副作用的例子包括改变全局变量、写入到硬盘、读取用户控制台的输入、通过网络接收数据。
纯函数唯一可以做的事情就是对输入参数进行转换并返回新的值。这不仅使纯函数的推导变得简单,也可以很容易地把它们组合起来。只要一个纯函数的返回值是另一个纯函数的有效参数,对它们的组合就肯定是安全的:
result = pureFunction3(pureFunction2(pureFunction1(val)));
有副作用的函数在组合时就更困难一些。举例来说,BookParser中的convertCsvToJson函数会对文件系统进行读写,所以它不是纯的:
public void convertCsvToJson(File inputCsv, File outputJson) throws IOException {
注意该函数的签名中并没有返回值(它是void函数),这是有副作用的函数的典型特征。没有了返回值,我们就很难把这个函数和其他函数组合起来,这些函数将不得不通过文件系统或共享的可变变量进行通信,这两种方式都会比使用参数和返回值更加复杂,也更容易出错。
即便一个函数是有返回值的,它仍然可能会有副作用,这样对函数的推导和组合会更加困难。例如,要推导出convertCsvToJson的行为,光看其内部的代码或者它的签名是不够的,我们还必须知道外部世界的状态,比如CSV文件是否存在?有没有读取它的权限?如果在读取的时候有人开始覆盖文件会怎么样?JSON文件是不是已经存在了?有没有权限往里面写东西?如果在写入的时候,刚好有人开始写东西进去怎么办?硬盘上有没有足够的磁盘空间可以写入JSON文件?
不可变数据要求你要在脑海中弄清楚多条时间轴,副作用函数则要求你要在脑海中弄清楚多条时间轴和多个可能的全局状态。把几个有副作用的函数组合起来可能会引起所有时间轴和状态相互间进行交互,导致复杂度呈现指数式增长。
我认为可重用性的缺乏存在于面向对象语言,而不是函数式语言,因为面向对象语言的问题是它们离不开各种隐性的环境。你要一根香蕉,但得到的却是一只拿着香蕉的大猩猩和整个丛林。
如果你的代码是引用透明的,如果你有纯函数——所有的数据都来自输入的参数,输出的所有东西也没有留下什么状态——那么它的可重用性将是惊人的。
——Joe Armstrong,Erlang之父
如果我们写的多是像纯函数一样的代码,就会发现代码变得更容易推导和重用。当然,如果想让代码有些用处,某些时候和现实之间的交互就是不可避免的,所以也无法完全摆脱副作用,最好的办法就是控制和管理这些副作用。
在像Haskell这样的编程语言中,只有运行时才能够执行有副作用的操作。如果我们尝试在自己的代码中直接执行有副作用的操作,就会得到一个编译错误。看看下面这句伪代码:
def main(): someSideEffect()
如果这是Haskell代码,那是没法通过编译的,因为代码尝试直接去执行一个有副作用的操作。如果想要在Haskell中实现上述代码的功能,我们需要把有副作用的代码放在名为IO的类型中,这样代码从main方法返回时,Haskell运行时才会去执行有副作用的操作:
def main(): return IO(someSideEffect)
因为Haskell是静态类型语言,所以IO类型也是函数签名的一部分,这意味着副作用操作是该语言的一等公民,我们可以对它们进行传递、组合,让编译器进行检查:
main:: IO ()
在其他大多数语言(比如Java语言)中,副作用在很大程度上是看不出来的。任何函数,不管它的签名是什么,都可以进行网络调用,改变全局变量,哪怕发射导弹。除了转到纯函数式语言之外,没有什么简单的解决办法。最好的方法就是把有副作用的代码尽可能地隔离到几个地方,尽最大努力保证方法签名和文档可以精确地反映所有副作用。在某些情况下,可以模仿Haskell的方式,把副作用操作推到应用程序的入口点,比如命令行应用程序中的main方法或者Web服务器的HTTP请求处理程序。
举例来说,BookParser类并不存在读写文件的需要。这个类的目的就是解析CSV数据并将其转换为JSON数据。但事实上,这些数据是不是在硬盘上根本不重要,我们可以修改代码,把CSV数据作为一个String获取,返回的JSON数据也作为一个String,完全避免和文件系统打交道:
public class BookParser { public String convertCsvToJson(String csv) throws IOException { List books = parseBooksFromCsvString(csv); return writeBooksAsJsonString(books); } public List parseBooksFromCsvString(String csv) throws IOException { List records = CSVFormat .DEFAULT .withHeader(TITLE.name(), AUTHOR.name(), PAGES.name(), CATEGORY.name()) .parse(new StringReader(csv)) .getRecords(); return records .stream() .map(this::parseBookFromCsvRecord) .collect(Collectors.toList()); } public Book parseBookFromCsvRecord(CSVRecord record) { String title = record.get(TITLE); String author = record.get(AUTHOR); int pages = Integer.parseInt(record.get(PAGES)); Category category = Category.valueOf(record.get(CATEGORY)); return new Book(title, author, pages, category); } public String writeBooksAsJsonString(List books) throws JsonProcessingException { ObjectMapper mapper = new ObjectMapper(); return mapper.writeValueAsString(books); } }
现在BookParser中的每一个函数都是纯函数:每个函数都读入某些输入参数,做一些转换之后返回一个值,期间没有产生任何副作用。这样就使这些函数易于阅读、维护和重用。而BookParser的客户端既可以控制填入CSV数据的方式,也可以控制对JSON输出的处理,我们还可以把这段代码用在更广泛的用途中。比如下面的代码就展示了从命令行使用BookParser的方式:
public class Main { public static void main(String[] args) throws IOException { String inputCsv = args[0]; String outputJson = args[1]; String csv = IOUtils.toString(new FileInputStream(inputCsv)); String json = new BookParser().convertCsvToJson(csv); IOUtils.write(json, new FileOutputStream(outputJson)); } }
过去所有存在于BookParser中的副作用(即从磁盘读写的操作),现在都被隔离到main方法中了。这也是这个应用的入口点,是天生进行I/O操作的地方。事实上,所有BookParser函数现在都变成了纯函数,所以也可以很轻松地编写它们的单元测试。例如下面是针对convertCsvToJson函数的一个简单的JUnit测试(阅读7.2.1节了解更多信息):
@Test public void testConvertCsvToJson() throws Exception { String csv = "Code Complete,Steve McConnell,960,nonfiction"; String expected = "[{\"title\":\"Code Complete\"," + "\"author\":\"Steve McConnell\"," + "\"pages\":960," + "\"category\":\"nonfiction\"}]"; String actual = new BookParser().convertCsvToJson(csv); Assert.assertEquals(expected, actual); }
当我们不需要担心副作用的时候,这个单元测试就更容易编写了:不需要清理硬盘上的CSV或JSON文件;当代码并行执行的时候,测试也不存在覆盖彼此文件的可能;完成后不需要清理任何文件。