函数式编程思维第二章转变思维

作者: 每天学点编程 | 来源:发表于2017-09-28 19:52 被阅读142次

    请关注我的微信公众号


    个人微信公众号

    技术交流群 (仅作技术交流):642646237
    请关注我的头条号:


    学习新的编程语言

    把熟悉的概念用新的语法表达出来
    可以通过套用自己已经在别的语言中掌握的知识来学习新的语言。

    学习一种新的范式

    为熟悉的问题找到新的解答方法。
    换用函数式编程语言并不是写出函数式代码的必要条件,转变看待问题的角度才是必不可少的。

    普通的例子

    算法编写上:
    一方面程序员得以在更高的抽象层次上工作,
    另一方面运行时也有了执行复杂优化的自由空间。

    开发者从中获得的好处体现在更低的复杂性和更高的性能,这点与垃圾收集相同,不过,函数式编程对个人的影响更直接,因为它改变的是解答思路。

    普通的例子——命令式解法

    命令式编程是按照“程序是一系列改变状态的命令”来建模的一种编程风格。
    传统的for循环是命令式风格的绝好例子:先确立初始状态,然后每次迭代都执行循环体中的一系列命令。

    假设有一个名字列表,其中一些条目由单个字符构成。现在的任务是,将除去单字符条目之外的列表内容,放在一个逗号分隔的字符串里返回,且每个名字的首字母都要大写。

    由于必定要遍历整个列表,那么最方便下手操作的地方,自然就是在一个命令式循环的内部。每迭代一个名字,都检查它的长度是否大于一个字符的保留门槛,然后调整其首字母为大写后,连同作为分隔符的逗号一起,追加到result。最后一个名字不应该有尾随的逗号,所以从最后的返回值里去掉了这个多余的分隔符。
    命令式编程鼓励程序员将操作安排在循环内部去执行:

    • filter,筛选列表,去除单字符条目;
    • transform,变换列表,使名字的首字母变成大写;
    • convert,转换列表,得到单个字符串。

    在命令式语言里,这三种操作都必须依赖于相同的低层次机制(对列表进行迭代)。而函数式语言为这些操作提供了针对性的辅助手段。

    普通的例子——函数式解法

    函数式编程将程序描述为表达式和变换,以数学方程的形式建立模型,并且尽量避免可变的状态。
    函数式编程语言对问题的归类不同于命令式语言。filter、transform、convert等每一种都作为一个逻辑分类由不同的函数所代表,这些函数实现了低层次的变换,但依赖于开发者定义的高阶函数作为参数来调整其低层次运转机构的运作。

    listOfEmps
        -> filter(x.length > 1)
        -> transform(x.capitalize)
        -> convert(x + "," + y)
    

    函数式语言可以帮助我们轻松搭建出上面的概念性解答模型,同时又不必操心各种实现细节。

    普通的例子——函数式解法——Scala版本

    普通的例子——函数式解法——Java8版本

    collect()方法取代了reduce(),原因是它操作Java的String类的效率更高;collect()是Java 8针对某些情形而提供的reduce()的特殊实现。

    如果担心某些列表元素可能为null,那么只要在stream后面多加一条检查就可以了:

    Java运行时会聪明地将null检查和针对长度的筛选合并成一次操作,这样既不妨碍把意图表达清楚,又不损失代码的执行效率。

    普通的例子——函数式解法——Groovy版本

    Groovy语言命名上更接近Ruby等脚本语言。

    Groovy的findAll方法对集合中的元素执行参数里传入的代码块,只留下结果为true的元素。Groovy允许开发者简写只带一个参数的代码块,它规定用it关键字来代表这个唯一的参数,无需定义。Groovy的collect方法相当于前面的map,负责对集合中的每个元素执行参数里传入的代码块。join()函数的功能是用参数中指定的分隔符,把一个字符串集合串接起来,拼成单一的字符串。

    普通的例子——函数式解法——Clojure版本

    Clojure是一种函数式语言,它的函数命名上自然更传统一些。

    Lisp家族的Clojure是“由内向外”执行的,因此起点其实在最后一个参数值list-of-emps。Clojure的(filter a b)函数接受两个参数:作为筛选条件的函数(例中为匿名函数)和将要被筛选的集合。第一个参数也可以写成完整的函数定义(fn [x] (< 1 (count x))),不过Clojure允许使用更简短的匿名函数形式#(< 1 (count %))
    (map a b)函数的第一个参数是变换函数,第二个参数是待变换的集合,也就是上一步(filter )操作的返回值。可以专门定制一个函数来作为(map )的第一个参数,不过既然任何单参数的函数都符合(map )的要求,所以直接用能够满足需求的Clojure内建函数capitalize即可。最后,(map )操作的输出成为下一步(reduce )操作的集合参数。(reduce )的第一个参数是负责拼合字符串的(str )函数,(str )作用于(interpose )函数的返回值,而(interpose )负责在(map )返回集合的元素之间插入它的第一个参数指定的分隔符。
    通过thread-last宏改善代码的


    Clojure的thread-last宏(即->>符号)针对的是非常常见的各种集合变换操作,它把典型的Lisp书写顺序颠倒了过来,重整为更自然的从左到右的阅读顺序。首先看到的是集合本身(list-of-emps),然后才是依次作用于前一个语法单元(form)的连串变换操作。Lisp灵活的语法正是它最强大的武器之一:什么时候可读性变差了,就调整语法去满足可读性。

    函数式思维 好处

    向函数式思维靠拢,意味着我们逐渐学会何时何地应该求助于这些更高层次的抽象,不要再一头扎到实现细节里去。
    学会用更高层次的抽象来思考有什么好处?首先,会促使我们换一种角度去归类问题,看到问题的共性。其次,让运行时有更大的余地去做智能的优化。有时候,在不改变最终输出的前提下,调整一下作业的先后次序会更有效率(例如减少了需要处理的条目)。第三,让埋头于实现细节的开发者看到原本视野之外的一些解决方案。

    普通的例子——函数式解法——多线程版本——Scala版本

    命令式编程自己控制着低层次的迭代细节,那么线程相关的代码也就只好由自己动手穿插进去。可是换作Scala的实现,只要在stream上多调用一次par方法就可以了。

    普通的例子——函数式解法——多线程版本——Java版本

    普通的例子——函数式解法——多线程版本——解读

    Clojure同样只需简单替换,就能够将一般的集合变换操作不动声色地并行化。在更高的抽象层次上做事情,运行时才好去优化低层次的细节。

    编写带垃圾收集的工业级虚拟机实在是一项异常复杂的任务,开发者乐得交出这方面的职责。另一边的JVM工程师则尽力封装起垃圾收集,让它从开发者的日常考虑事项中消失,大大减轻了开发者的负担。

    多从结果着眼,少纠结具体的步骤。
    不要再让那些迭代、变换、化约如何进行的低层次细节占据你的思维,多想想哪些问题其实可以归结为这几样基本操作的排列组合。

    案例研究:完美数的分类问题

    任意一个自然数都唯一地被归类为过剩数(abundant)、完美数(perfect)或不足数(deficient)。一个完美数的真约数(即除了自身以外的所有正约数)之和,恰好等于它本身。例如6是一个完美数,因为它的约数是1、2、3,而6 = 1 + 2 + 3;28也是一个完美数,因为28 = 1 + 2 + 4 + 7 + 14。

    案例研究:完美数的分类问题——自然数分类规则

    完美数 真约数之和 = 数本身
    过剩数 真约数之和 > 数本身
    不足数 真约数之和 < 数本身

    案例研究:完美数的分类问题——实现不用“正约数和”

    实现中用到一个数学概念,真约数和(aliquot sum),其定义就是除了数本身之外(一个数总是它本身的约数),其余正约数的和。之所以不用“正约数和”来表述,是为了稍稍简化判定完美数时的比较语句:aliquotSum == number要比sum - number == number易读一些。

    案例研究:完美数的分类问题——完美数分类的命令式解法

    分类程序在使用中很可能需要对同一个数字进行多次分类,因此实现的时候有必要考虑这种情况。

    ImpNumberClassifierSimple类维持着两个内部状态。其中number字段的作用是为一系列函数省下一个参数。cache则通过一个Map结构来缓存每个数字的真约数和,以在后续针对同一个数字的调用中更快地返回结果(查表速度与计算速度的差别)。

    内部状态在面向对象编程的世界里是受到推崇的平常做法,因为封装被OOP语言视为一项优势。状态的划分往往为一些工程实践提供了便利,比如单元测试的时候很容易注入各种取值。

    代码经过了精心的组织,划分成很多个小方法。这是测试驱动开发的副产物,不过也正好把算法的各个组成部分都表现了出来。其中一些部分会在后续的改造中逐渐被替换成更加函数式的写法。

    案例研究:完美数的分类问题——稍微向函数式靠拢的完美数分类解法——“最小化共享状态”

    可以去掉类的成员变量,改为通过参数来传递需要的值。

    稍微向函数式风格靠拢的NumberClassifier里面,所有方法都是自足的、带publicstatic作用域的纯函数(即没有副作用的函数)。而由于类里面根本不存在任何内部状态,也就没有理由去“隐藏”任何一个方法。实际上,factors方法在很多其他应用中都有潜在的用途,比如用来寻找素数。

    一般来说,面向对象系统里粒度最小的重用单元是类,开发者往往忘记了重用可以在更小的单元上发生。aliquotSum方法的参数类型没有选择某一种具体的列表类型,而是定为Collection<Integer>。一个兼容于所有数字集合的接口,在函数级别上发生重用的可能性自然更大一些。
    这一版的实现没有为求和结果设计缓存机制。缓存意味着持续存在的状态,可是这一版的实现根本没有可以放置状态的地方。对比上一版,这一版的效率上要低一些。这是因为失去了存放求和结果的内部状态,只好每次都重新计算。

    案例研究:完美数的分类问题——完美数分类的Java 8实现

    lambda块其实就是高阶函数。


    比原来的命令式解法以及不完全的函数式版本短得多,也简单得多。factorsOf()方法返回了一个IntStream,为后续串连其他操作,包括令stream产生数值输出的终结操作提供了方便。换言之,factorsOf()没有直接返回一个整数列表,而是给了一个尚未产生任何输出的streamaliquotSum()方法很好写,无非是对约数的列表求和,再减去数本身。不需要自行编写求和用的sum()方法,因为Java 8已经为stream准备了这样一个产生输出的终结操作。
    物理上把机械能分成储蓄起来的势能和释放出来的动能。在版本8以前的Java,以及它所代表的许多语言里,集合的行为可以比作动能:各种操作都立即求得结果,不存在中间状态。函数式语言里的stream则更像势能,它的操作可以引而不发。被stream储蓄起来的有数据来源(例中的数据来源是range()方法),还有对数据设置的各种条件,如例中的筛选操作。只有当程序员通过forEach()sum()终结操作来向stream“要”求值结果的时候,才触发从“势能”到“动能”的转换。在“动能”开始释放之前,stream可以作为参数传递并后续附加更多的条件,继续积蓄它的“势能”。这里关于“势能”的比喻,用函数式编程的说法叫作缓求值(lazy evaluation)。

    案例研究:完美数的分类问题——完美数分类的Functional Java实现

    开源框架Functional Java针对1.5以上版本的Java运行时,以尽可能低的侵入性为代价引入了尽量多的函数式编程手法。Functional Java可以通过泛型和匿名内部类,在Java 1.5时代的JDK上模拟出它所缺少的高阶函数特性。


    Functional Java在其List类中提供的foldLeft()方法为aliquotSum()提供了很大的便利。“fold left”(即左折叠操作)的含义是:
    (1)用一个操作(或者叫运算)将初始值(例中为0)与列表中的第一个元素结合;
    (2)继续用同样的操作将第1步的运算结果与下一个元素结合;
    (3)反复进行直到消耗完列表中的元素。
    这几个步骤正好就是对数字列表求和的一般做法:从0开始,先和第一个元素相加,结果再和第二个元素相加,以此类推直到列表结尾。Functional Java提供了运算所需的高阶函数(Integers.add函数),也由它负责施用。

    Functional Java也无法在旧版本的Java里实现完整的高阶函数功能,只是用匿名内部类来模拟高阶函数的编程风格。

    factorsOf()方法很好地体现了“多着眼结果,少纠结步骤”的格言。寻找一个数的约数,这个问题的实质是什么?或者可以换一种方式来叙述:在从1到目标数字的整数列表里,怎么确定其中哪些数字是目标数的约数?这样一来,筛选操作就呼之欲出了——可以逐一筛选列表中的元素,去除那些不满足筛选条件的数字。
    factorsOf()方法的作为基本上可以用一句话来描述:对于从1到目标数字的区间(不包含区间的右侧端点,因此代码中将区间上限写成number + 1),以f()方法中的代码来筛选区间内数字所构成的一个列表,F类和f()方法是Functional Java留给我们“填空”数据类型和返回值的地方。

    foldLeft()方法依次向左方,即向着第一个元素合并列表。对于满足交换律的加法来说,折叠的方向并不影响结果。万一需要使用某些结果与折叠次序相关的操作,还有foldRight()方法可供选择。

    高阶函数消除了摩擦。

    不能认为Functional Java版本与Java 8版本的区别无非是一些语法糖衣(其实不止)。可是语法上的便利也是很重要的方面,毕竟想表达的意思都要由语法来承载。

    语法承载着思维方式。在语法处处掣肘下塑造出来的抽象,很难配合思维过程而不产生无谓的摩擦。
    不要增加无谓的摩擦。

    Clojure安装教程

    在官网下载clojure-1.8.0,目录结果如下:



    运行如下命令:

    java -cp clojure-1.8.0.jar clojure.main
    

    这只是进入了Clojure的命令模式;


    相关文章

      网友评论

        本文标题:函数式编程思维第二章转变思维

        本文链接:https://www.haomeiwen.com/subject/hzkkextx.html