《Java8实战》笔记(13):函数式的思考(1)

专注于如何实现

How to do

一种专注于如何实现,比如:“首先做这个,紧接着更新那个,然后……”

举个例子,如果你希望通过计算找出列表中最昂贵的事务,通常需要执行一系列的命令:

  • 从列表中取出一个事务,将其与临时最昂贵事务进行比较;

  • 如果该事务开销更大,就将临时最昂贵的事务设置为该事务;

  • 接着从列表中取出下一个事务,并重复上述操作。

这种“如何做”风格的编程非常适合经典的面向对象编程,有些时候我们也称之为“命令式”,因为它的特点是它的指令和计算机底层的词汇非常相近,比如赋值、条件分支以及循环,就像下面这段代码:

Transaction mostExpensive = transactions.get(0);

if(mostExpensive == null)

throw new IllegalArgumentException(“Empty list of transactions”)

for(Transaction t: transactions.subList(1, transactions.size())){

if(t.getValue() > mostExpensive.getValue()){

mostExpensive = t;

}

}

关注要做什么

what to do

另一种方式则更加关注要做什么。使用Stream API你可以指定下面这样的查询:

Optional mostExpensive = transactions.stream()

.max(comparing(Transaction::getValue));

这个查询把最终如何实现的细节留给了函数库。我们把这种思想称之为内部迭代。它的巨大优势在于你的查询语句现在读起来就像是问题陈述,由于采用了这种方式,比理解一系列的命令要简洁得多。

采用这种“要做什么”风格的编程通常被称为声明式编程。你制定规则,给出了希望实现的目标,让系统来决定如何实现这个目标。它带来的好处非常明显,用这种方式编写的代码更加接近问题陈述了。

为什么要采用函数式编程

函数式编程具体实践了声明式编程(“你只需要使用不相互影响的表达式,描述想要做什么,由系统来选择如何实现”)和无副作用计算,这两个思想能帮助你更容易地构建和维护系统。

一些语言的特性,比如构造操作和传递行为对于以自然的方式实现声明式编程是必要的,它们能让我们的程序更便于阅读,易于编写。你可以使用Stream将几个操作串接在一起,表达一个复杂的查询。这些都是函数式编程语言的特性

什么是函数式编程


对于“什么是函数式编程”这一问题最简化的回答是“它是一种使用函数进行编程的方式”。那什么是函数呢?

很容易想象这样一个方法,它接受一个整型和一个浮点型参数,返回一个浮点型的结果——它也有副作用,随着调用次数的增加,它会不断地更新共享变量,如下图所示。

在函数式编程的上下文中,一个“函数”对应于一个数学函数:它接受零个或多个参数,生成一个或多个结果,并且不会有任何副作用。你可以把它看成一个黑盒,它接收输入并产生一些输出

这种类型的函数和你在Java编程语言中见到的函数之间的区别是非常重要的(我们无法想象,log或者 sin这样的数学函数会有副作用)。尤其是,使用同样的参数调用数学函数,它所返回的结果一定是相同的。


当谈论“函数式”时,我们想说的其实是“像数学函数那样——没有副作用”。由此,编程上的一些精妙问题随之而来。我们的意思是,每个函数都只能使用函数和像if-then-else这样的数学思想来构建吗?

或者,我们也允许函数内部执行一些非函数式的操作,只要这些操作的结果不会暴露给系统中的其他部分?换句话说,如果程序有一定的副作用,不过该副作用不会为其他的调用者感知,是否我们能假设这种副作用不存在呢?调用者不需要知道,或者完全不在意这些副作用,因为这对它完全没有影响。

当我们希望能界定这二者之间的区别时,我们将第一种称为纯粹的函数式编程,后者称为函数式编程

函数式Java编程

编程实战中,你是无法用Java语言以纯粹的函数式来完成一个程序的。

比如,Java的I/O模型就包含了带副作用的方法(调用Scanner.nextLine就有副作用,它会从一个文件中读取一行,通常情况两次调用的结果完全不同)。

不过,你还是有可能为你系统的核心组件编写接近纯粹函数式的实现。在Java语言中,如果你希望编写函数式的程序,首先需要做的是确保没有人能觉察到你代码的副作用,这也是函数式的含义。假设这样一个函数或者方法,它没有副作用,进入方法体执行时会对一个字段的值加一,退出方法体之前会对该字段减一。对一个单线程的程序而言,这个方法是没有副作用的,可以看作函数式的实现。

换个角度而言,如果另一个线程可以查看该字段的值——或者更糟糕的情况,该方法会同时被多个线程并发调用——那么这个方法就不能称之为函数式的实现了。

当然,你可以用加锁的方式对方法的方法体进行封装,掩盖这一问题,你甚至可以再次声称该方法符合函数式的约定。但是,这样做之后,你就失去了在你的多核处理器的两个核上并发执行两个方法调用的能力。它的副作用对程序可能是不可见的,不过对于程序员你而言是可见的,因为程序运行的速度变慢了!

我们的准则是,被称为“函数式”的函数或方法都只能修改本地变量。除此之外,它引用的对象都应该是不可修改的对象。通过这种规定,我们期望所有的字段都为final类型,所有的引用类型字段都指向不可变对象。后续的内容中,你会看到我们实际也允许对方法中全新创建的对象中的字段进行更新,不过这些字段对于其他对象都是不可见的,也不会因为保存对后续调用结

果造成影响。


我们前述的准则是不完备的,要成为真正的函数式程序还有一个附加条件,不过它在最初时不太为大家所重视。要被称为函数式,函数或者方法不应该抛出任何异常。关于这一点,有一个极为简单而又极为教条的解释:你不应该抛出异常,因为一旦抛出异常,就意味着结果被终止了;不再像我们之前讨论的黑盒模式那样,由return返回一个恰当的结果值。

不过,这一规则似乎又和我们实际的数学使用有冲突:虽然合法的数学函数为每个合法的参数值返回一个确定的结果,很多通用的数学操作在严格意义上称之为局部函数式(partial function)可能更为妥当。这种函数对于某些输入值,甚至是大多数的输入值都返回一个确定的结果;不过对另一些输入值,它的结果是未定义的,甚至不返回任何结果。

这其中一个典型的例子是除法和开平方运算,如果除法的第二操作数是0,或者开平方的参数为负数就会发生这样的情况。以Java那样抛出一个异常的方式对这些情况进行建模看起来非常自然。这里存在着一定的争执,有的作者认为抛出代表严重错误的异常是可以接受的,但是捕获异常是一种非函数式的控制流,因为这种操作违背了我们在黑盒模型中定义的“传递参数,返回结果”的规则,引出了代表异常处理的第三支箭头,如下图所示。

那么,如果不使用异常,你该如何对除法这样的函数进行建模呢?答案是请使用Optional类型:你应该避免让sqrt使用double sqrt(double)这样的函数签名,因为这种方式可能抛出异常;与之相反我们推荐你使用Optional sqrt(double)——这种方式下,函数要么返回一个值表示调用成功,要么返回一个对象,表明其无法进行指定的操作。

当然,这意味着调用者需要检查方法返回的是否为一个空的Optional对象。这件事听起来代价不小,依据我们之前对函数式编程和纯粹的函数式编程的比较,从实际操作的角度出发,你可以选择在本地局部地使用异常,避免通过接口将结果暴露给其他方法,这种方式既取得了函数式的优点,又不会过度膨胀代码。

最后,作为函数式的程序,你的函数或方法调用的库函数如果有副作用,你必须设法隐藏它们的非函数式行为,否则就不能调用这些方法(换句话说,你需要确保它们对数据结构的任何修改对于调用者都是不可见的,你可以通过首次复制,或者捕获任何可能抛出的异常实现这一目的)

引用透明性

“没有可感知的副作用”(不改变对调用者可见的变量、不进行I/O、不抛出异常)的这些限制都隐含着引用透明性如果一个函数只要传递同样的参数值,总是返回同样的结果,那这个函数就是引用透明的

String.replace方法就是引用透明的,因为像"raoul".replace(‘r’,‘R’)这样的调用总是返回同样的结果(replace方法返回一个新的字符串,用小写的r替换掉所有大写的R),而不是更新它的this对象,所以它可以被看成函数式的。

换句话说,函数无论在何处、何时调用,如果使用同样的输入总能持续地得到相同的结果,就具备了函数式的特征。

这也解释了我们为什么不把Random.nextInt看成函数式的方法。Java语言中,使用Scanner对象从用户的键盘读取输入也违反了引用透明性原则,因为每次调用nextLine时都可能得到不同的结果。不过,将两个final int类型的变量相加总能得到同样的结果,因为在这种声明方式下,变量的内容是不会被改变的。


引用透明性是理解程序的一个重要属性。它还包含了对代价昂贵或者需长时间计算才能得到结果的变量值的优化(通过保存机制而不是重复计算),我们通常将其称为记忆化或者缓存

Java语言中,关于引用透明性还有一个比较复杂的问题。假设你对一个返回列表的方法调用了两次。这两次调用会返回内存中的两个不同列表,不过它们包含了相同的元素。如果这些列表被当作可变的对象值(因此是不相同的),那么该方法就不是引用透明的。如果你计划将这些列表作为单纯的值(不可修改),那么把这些值看成相同的是合理的,这种情况下该方法是引用透

明的。通常情况下,在函数式编程中,你应该选择使用引用透明的函数。

面向对象的编程和函数式编程的对比

我们由函数式编程和(极端)典型的面向对象编程的对比入手进行介绍,最终你会发现Java8认为这些风格其实只是面向对象的一个极端。作为Java程序员,毫无疑问,你一定使用过某种函数式编程,也一定使用过某些我们称为极端面向对象的编程。由于硬件(比如多核)和程序员期望(比如使用类数据库查询式的语言去操纵数据)的变化,促使Java的软件工程风格在某种程度上愈来愈向函数式的方向倾斜。

关于这个问题有两种观点。

  1. 一种支持极端的面向对象:任何事物都是对象,程序要么通过更新字段完成操作,要么调用对与它相关的对象进行更新的方法。

  2. 另一种观点支持引用透明的函数式编程,认为方法不应该有(对外部可见的)对象修改。

实际操作中,Java程序员经常混用这些风格。你可能会使用包含了可变内部状态的迭代器遍历某个数据结构,同时又通过函数式的方式计算数据结构中的变量之和。

函数式编程实战

SubsetsMain

一个示例函数式的编程练习题:给定一个列表List,比如{1, 4, 9},构造一个List<List>,它的成员都是类表{1, 4, 9}的子集——暂时不考虑元素的顺序。{1, 4, 9}的子集是{1, 4, 9}、{1, 4}、{1, 9}、{4, 9}、{1}、{4}、{9}以及{}。

包括空子集在内,这样的子集总共有8个。每个子集都使用List表示,这就是答案中期望的List类型。

对于“{1, 4, 9}的子集可以划分为包含1和不包含1的两部分”也需要特别解释。不包含1的子集很简单就是{4, 9},包含1的子集可以通过将1插入到{4, 9}的各子集得到。这样我们就能利用Java,以一种简单、自然、自顶向下的函数式编程方式实现该程序了(一个常见的编程错误是认为空的列表没有子集)

static List<List> subsets(List list) {

if (list.isEmpty()) {

List<List> ans = new ArrayList<>();

ans.add(Collections.emptyList());

return ans;

}

Integer first = list.get(0);

List rest = list.subList(1,list.size());

List<List> subans = subsets(rest);

List<List> subans2 = insertAll(first, subans);

return concat(subans, subans2);

}

如果给出的输入是{1, 4, 9},程序最终给出的答案是{{}, {9}, {4}, {4, 9}, {1}, {1, 9}, {1, 4}, {1, 4, 9}}。

假设缺失的方法insertAll和concat自身都是函数式的,并依此推断你的subsets方法也是函数式的,因为该方法中没有任何操作会修改现有的结构。这就是著名的归纳法。

static List<List> insertAll(Integer first,

List<List> lists) {

List<List> result = new ArrayList<>();

for (List list : lists) {

List copyList = new ArrayList<>();

copyList.add(first);

copyList.addAll(list);

result.add(copyList);

}

return result;

}


但是我们希望你不要这样使用

static List<List> concat(List<List> a, List<List> b) {

a.addAll(b);

return a;

}

不过,我们真正建议你采用的是下面这种方式:

static List<List> concat(List<List> a, List<List> b) {

List<List> r = new ArrayList<>(a);

r.addAll(b);

return r;

}

第二个版本的concat是纯粹的函数式。虽然它在内部会对对象进行修改(向列表r添加元素),但是它返回的结果基于参数却没有修改任何一个传入的参数。与此相反,第一个版本基于这样的事实,执行完concat(subans, subans2)方法调用后,没人需要再次使用subans的值。对于我们定义的subsets,这的确是事实,所以使用简化版本的concat是个不错的选择。不过,这也取决于你如何审视你的时间,你是愿意为定位诡异的缺陷费劲心机耗费时间呢?还是花费些许的代价创建一个对象的副本呢?

无论你怎样解释这个不太纯粹的concat方法,“只会用于第一参数可以被强制覆盖的场景,或者只会使用在这个subsets方法中,任何对subsets的修改都会遵照这一标准进行代码评审”,一旦将来的某一天,某个人发现这段代码的某些部分可以复用,并且似乎可以工作时,你未来调试的梦魇就开始了。

请牢记:考虑编程问题时,采用函数式的方法,关注函数的输入参数以及输出结果(即你希望做什么),通常比设计阶段的早期就考虑如何做、修改哪些东西要卓有成效得多

递归和迭代


Recursion

纯粹的函数式编程语言通常不包含像while或者for这样的迭代构造器。因为这种类型的构造器经常隐藏着陷阱,诱使你修改对象。

比如,while循环中,循环的条件需要更新;否则循环就一次都不会执行,要么就进入无限循环的状态。但是,很多情况下循环还是非常有用的。

如果没有人能感知的话,函数式也允许进行变更,这意味着我们可以修改局部变量。我们在Java中使用的for-each循环,for(Apple a : apples { }如果用迭代器方式重写,代码如下:

Iterator it = apples.iterator();

while (it.hasNext()) {

Apple apple = it.next();

// …

}

这并不是问题,因为改变发生时,这些变化(包括使用next方法对迭代器状态的改变以及在while循环内部对apple变量的赋值)对于方法的调用方是不可见的。但是,如果使用for-each循环,比如像下面这个搜索算法就会带来问题,因为循环体会对调用方共享的数据结构进行修改:

public void searchForGold(List l, Stats stats){

for(String s: l){

if(“gold”.equals(s)){

stats.incrementFor(“gold”);

}

}

}

实际上,对函数式而言,循环体带有一个无法避免的副作用:它会修改stats对象的状态,而这和程序的其他部分是共享的。

由于这个原因,纯函数式编程语言,比如Haskell直接去除了这样的带有副作用的操作!之后你该如何编写程序呢?比较理论的答案是每个程序都能使用无需修改的递归重写,通过这种方式避免使用迭代。使用递归,你可以消除每步都需更新的迭代变量。一个经典的教学问题是用迭代的方式或者递归的方式(假设输入值大于1)编写一个计算阶乘的函数(参数为正数),代码如下。

迭代式的阶乘计算

static int factorialIterative(int n) {

int r = 1;

for (int i = 1; i <= n; i++) {

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数Java工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年Java开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Java开发知识点,真正体系化!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注Java获取)

img

最后

小编精心为大家准备了一手资料

以上Java高级架构资料、源码、笔记、视频。Dubbo、Redis、设计模式、Netty、zookeeper、Spring cloud、分布式、高并发等架构技术

【附】架构书籍

  1. BAT面试的20道高频数据库问题解析
  2. Java面试宝典
  3. Netty实战
  4. 算法

BATJ面试要点及Java架构师进阶资料

《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!
t=“img” style=“zoom: 33%;” />

最后

小编精心为大家准备了一手资料

[外链图片转存中…(img-JKaJfNzp-1713513750083)]

[外链图片转存中…(img-PzjuHhYz-1713513750084)]

以上Java高级架构资料、源码、笔记、视频。Dubbo、Redis、设计模式、Netty、zookeeper、Spring cloud、分布式、高并发等架构技术

【附】架构书籍

  1. BAT面试的20道高频数据库问题解析
  2. Java面试宝典
  3. Netty实战
  4. 算法

[外链图片转存中…(img-9RVNhuZP-1713513750086)]

BATJ面试要点及Java架构师进阶资料

[外链图片转存中…(img-A7JloOFo-1713513750087)]

《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!

  • 4
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值