函数式思维: 为什么函数式编程越来越受关注

到目前为止,在本系列的每期文章中,我都说明了为什么理解函数式编程非常重要。但是,有些原因是在多期文章中进行说明的,只有在综合思路的更大背景中,才可以完全了解这些原因。在本期文章中,我会探讨函数式编程方兴未艾的所有原因,并综合前几期文章中的一些个人经验教训。

在计算机科学短短的发展历史中,技术的主流有时会产生分支,包括实用分支和学术分支。20 世纪 90 年代的 4GL(第四代语言)是一个实用分支,而函数式编程是来自学术界的一个示例。每隔一段时间,都会有一些分支加入主流,函数式编程目前也是这种情况。函数式语言不仅在 JVM 上刚刚崭露头脚(其中两个最有趣的新语言是 Scala 和 Clojure),在 .NET 平台上也是才开始得到应用,在 .NET 平台上,F# 是头等公民。为什么所有平台都如此欢迎函数式编程?答案是,随着时间的推移,随着运行时都要能够处理更多的繁忙工作,开发人员已经能够将日常任务的更多控制权割让给它们。

割让控制权

在 20 世纪 80 年代初,在我上大学的时候,我们使用一个被称为 Pecan Pascal 的开发环境。其独特的特性是,相同的 Pascal 代码可以在 Apple II 或 IBM PC 上运行。Pecan 工程师使用某个称为 “字节码” 的神秘东西实现了这一壮举。开发人员将 Pascal 代码编译为 “字节码”,它可以在每个平台本地编写的 “虚拟机” 上运行。这是一个可怕的体验!所生成的代码慢得让人痛苦,甚至简单的类赋值也非常缓慢。当时的硬件还没有准备好迎接这个挑战。

在发布 Pecan Pascal 之后的十年,Sun 发布了 Java,Java 使用了相同的架构,对于 20 世纪 90 年代中期的硬件环境,运行该代码显得有些紧张,但最终取得了成功。Java 还增加了其他开发人员友好的特性,如自动垃圾收集。使用过像 C++ 这样的语言之后,我再也不想在没有垃圾收集的语言中编写代码。我宁愿花将时间花在更高层次上的抽象上,思考解决复杂业务问题的方法,也不愿意在内存管理等复杂的管道问题上浪费时间。

Java 缓解了我们与内存管理的交互;函数式编程语言使我们能够用高层次的抽象取代其他核心构建块,并更注重结果而不是步骤。

结果比步骤更重要

函数式编程的特点之一是存在强大的抽象,它隐藏了许多日常操作的细节(比如迭代)。我在本系列文章中一直使用的一个示例是数字分类:确定某个数字是 perfectabundant 还是 deficient。清单 1 中显示的 Java 实现可以解决这个问题:

清单 1. 自带缓存总数的 Java 数字分类器

清单 1 中的代码是典型的 Java 代码,它使用迭代来确定和汇总系数。在使用函数式编程语言时,开发人员很少关心细节(比如迭代,由 calculateFactors() 使用)和转换(比如汇总一个列表,该列表由 sumFactors() 使用),宁愿将这些细节留给高阶函数和粗粒度抽象。

粗粒度的抽象

用抽象来处理迭代等任务,使得需要维护的代码变得更少,因此可能出现错误的地方也就更少。清单 2 显示了一个更简洁的数字分类器,用 Groovy 编写,借用了 Groovy 的函数风格方法:

清单 2. Groovy 数字分类器

清单 2 中的代码使用很少的代码完成 清单 1 的所有工作(减去缓存总数,这会重新出现在下面的示例中)。例如,用于确定factorsOf() 中的系数的迭代消失了,替换为使用 findAll() 方法,它接受一个具有我的筛选器条件的代码块(一个高阶函数)。Groovy 甚至允许使用更简洁的代码块,它允许单参数块使用 it 作为隐含参数名称。同样,sumOfFactors() 方法使用了 inject(),它(使用 0 作为种子值)将代码块应用于每个元素,将每个对减少为单一的值。{i, j -> i + j} 代码块返回两个参数的总和;每次将列表 “折叠” 成一个对时,都会应用此块,产生总和。

Java 开发人员习惯于框架 级别的重用;在面向对象的语言中进行重用所需的必要构件需要非常大的工作量,他们通常会将精力留给更大的问题。函数式语言在更细化的级别提供重用,在列表和映射等基本数据结构之上通过高阶函数提供定制,从而实现重用。

少量数据结构,大量操作

在面向对象的命令式编程语言中,重用的单元是类以及与这些类进行通信的消息,这些信息是在类图中捕获的。该领域的开创性著作是《设计模式:可复用面向对象软件的基础》,至少为每个模式提供一个类图。在 OOP 的世界中,鼓励开发人员创建独特的数据结构,以方法的形式附加特定的操作。函数式编程语言尝试采用不同的方式来实现重用。它们更喜欢一些关键的数据结构(如列表、集和映射),并且在这些数据结构上采用高度优化的操作。传递数据结构和高阶函数,以便 “插入” 这种机制,针对某一特定用途对其进行定制。例如,在 清单 2 中,findAll() 方法接受使用一个代码块作为 “插件” 高阶函数(该函数确定了筛选条件),而该机制以有效方式应用了筛选条件,并返回经过筛选的列表。

函数级的封装支持在比构建自定义类结构更细的基础级别上进行重用。此方法的优势之一已经体现在 Clojure 中。最近,库中的一些巧妙创新重写了 map 函数,使它可以自动并行化,这意味着所有映射操作都可以受益于没有开发人员干预的性能提升。

例如,考虑一下解析 XML 的情况。大量的框架可用于在 Java 中完成这个任务,每个框架都有自定义的数据结构和方法语义(例如,SAX 与 DOM)。Clojure 将 XML 解析为一个标准的 Map 结构,而不是强迫您使用自定义的数据结构。因为 Clojure 中包含大量与映射配合使用的工具,如果使用内置的列表理解函数 for,那么执行 XPath 样式的查询就会很简单,如清单 3 所示:

清单 3. 将 XML 解释为 Clojure

在 清单 3 中,我访问雅虎的气象服务来获取某个给定城市的气象预报。因为 Clojure 是 Lisp 的一个变体,所有从内部读取是最简单的。对服务端点的实际调用发生在 (parse (format WEATHER-URI city-code)) 上,它使用了 String 的 format() 函数将 city-code嵌入字符串。列表理解函数 for 放置了解析后的 XML,使用 xml-seq 将它投放到名称为 x 的可查询映射中。:when 谓词确定了匹配条件;在本例中,我要搜索一个标签(转换成一个 Clojure 关键字) :yweather:condition

如欲了解从数据结构中读取值所用的语法,那么查看该语法中包含的内容会非常有用。在解析的时候,气象服务的相关调用会返回在此摘录中显示的数据结构:

因为已经为了与映射配合使用而优化了 Clojure,所以关键字在包含它们的映射上成为了函数。在 清单 3 中,对 (:tag x) 的调用是一个缩写,它等同于 “从存储在 x 中的映射检索与 :tag 键对应的值”。因此,:yweather:condition 产生与该键关联的映射值,其中包括我使用相同语法从中提取 :temp 的 attrs

最初,Clojure 中令人生畏的细节之一是:与映射和其他核心数据结构进行交互的方法似乎有无限多种。然而,它反映了这样一个事实:在 Clojure 中,大多数内容都尝试解决这些核心的、优化的数据结构。它没有将解析的 XML 困在一个独特的框架中,相反,它试图将其转换为一个已存在相关工具的现有结构。

对基础数据结构的依赖性的优点体现在 Clojure 的 XML 库中。为了遍历树形结构(如 XML 文档),1997 年创建了一个有用的数据结构,名为 zipper(参阅 参考资料)。zipper 通过提供坐标系方向,让您可以结构性地导航树。例如,可以从树的根开始,发出 (-> z/down z/down z/left) 等命令,导航到第二级的左侧元素。Clojure 中已经有现成的函数可将解析的 XML 转换为 zipper,在整个树形结构中实现一致的导航。

新的、不同的工具

函数式编程提供了新的工具类型,以优雅的方式解决棘手的问题。例如,Java 开发人员不习惯尽能延迟生成其值的惰性 数据结构。而未来的函数式语言将对这种高级特性提供支持,一些框架将此功能加装到 Java 中。例如,清单 4 所示的数字分类器版本使用了 Totally Lazy 框架:

清单 4. Java 数字分类器通过 Totally Lazy 使用惰性和函数式数据结构

Totally Lazy 增加了惰性集合和流畅接口方法,大量使用静态导入,使代码具有可读性。如果您羡慕下一代语言中的某些特性,那么一些研究可能会提供可以解决某个特定问题的特定扩展。

让语言迁就问题

大多数开发人员都将他们的工作误解为接受一个复杂的业务问题,将它转换成 Java 等语言。他们的这种误解是因为 Java 并不是一种特别灵活的语言,它迫使您让自己的想法适应于已经存在的刚性结构。但是,当开发人员使用可塑语言时,他们看到了让语言迁就问题,而不是让问题迁就语言的机会。像 Ruby(它为领域特定语言 (DSL) 提供了比主流更友好的支持)等语言证明了这种潜在可能。现代函数式语言甚至走得更远。Scala 旨在协调内部 DSL 的托管,并且所有 Lisp(包括 Clojure)都可以提供无与伦比的灵活性,使开发人员能够让语言适应问题。例如,清单 5 使用了 Scala 中的 XML 基元来实现 清单 3 的天气示例:

清单 5. Scala 的 XML 语法修饰

Scala 是为获得可塑性而设计的,它支持操作符重载和隐式类型等扩展。在 清单 5 中,Scala 被扩展为可以使用 \\ 操作符支持类似 XPath 的查询。

与语言的趋势相一致

函数式编程的目标之一是最大程度地减少可变状态。在 清单 1 中,有两种类型的共享状态清单。_factors 和 _number 都存在,它们使代码测试变得更容易(编写原代码版本是为了说明最大可测试性),并可以折叠成更大的函数,从而消除它们。但是,_sum 是因为各种原因而存在。我预计,这段代码的用户可能需要检查多个分类。(例如,如果一个完美的检查失败,那么下一次我可能会检查百分比。)合计系数总数的操作可能很昂贵,所以我为它创建了一个经过惰性初始化的访问器。在第一次调用时,它会计算总和,并将它存储在 _sum 成员变量中,以便优化未来的调用。

像垃圾收集一样,现在缓存也可以降级用于语言。清单 2 中的 Groovy 数字分类器忽略了 清单 1 中总数的惰性初始化。如果想要实现同样的功能,可以修改分类器,如清单 6 所示:

清单 6. 手动添加一个缓存

在最新版的 Groovy 中,清单 6 中的代码不再是必要的。考虑使用清单 7 中的改进版的分类器:

清单 7. 备忘数字分类器

任何纯函数(没有副作用的函数)都可以备忘,比如 清单 7 中的 sumOfFactors() 方法。备忘函数允许运行时缓存重复出现的值,从而消除手工编写缓存的需要。事实上,请注意执行实际工作的 getFactors() 和 factors() 方法之间的关系,该方法是备忘版本的getFactors()。Totally Lazy 还为 Java 增加了备忘功能,这是反馈到主流中的另一个高级函数特性。

由于运行时获得了更多的能力并且有多余的开销,开发人员可以将繁忙的工作割让给语言,将我们解放出来,去思考更重要的问题。Groovy 中的备忘功能就是众多示例中的一个;因为基础运行时允许这样做,所有现代语言都添加了函数式构造,包括 Totally Lazy 等框架。

结束语

因为运行时的能力变得更强,并且语言获得了更强大的抽象,所以开发世界变得更加函数化,这使开发人员可以花费更多的时间来思考结果的影响,而不是思考如何生成结果。由于高阶函数等抽象出现在语言中,它们将成为高度优化的操作的自定义机制。您不需要创建框架来处理问题(如 XML),您可以将其转换成您已经可以使用工具来处理的数据结构。

随着第 20 期文章的发布,函数式思维 将告一段落,我将准备开始一个新的系列,探索下一代的 JVM 语言。Java 下一代 会让您对不久的将来有一个大致了解,并帮助您对必须投入新语言学习的时间作出明智选择。

1 收藏 评论

相关文章

可能感兴趣的话题



直接登录
跳到底部
返回顶部