《Java8实战》笔记(14):函数式编程的技巧

另一个使用Tree的例子

PersistentTree

我们想讨论的对象是二叉查找树,它也是HashMap实现类似接口的方式。我们的设计中Tree包含了String类型的键,以及int类型的键值,它可能是名字或者年龄:

class Tree {

private String key;

private int val;

private Tree left, right;

public Tree(String k, int v, Tree l, Tree r) {

key = k; val = v; left = l; right = r;

}

}

class TreeProcessor {

public static int lookup(String k, int defaultval, Tree t) {

if (t == null) return defaultval;

if (k.equals(t.key)) return t.val;

return lookup(k, defaultval,

k.compareTo(t.key) < 0 ? t.left : t.right);

}

// 处理Tree的其他方法

}

你希望通过二叉查找树找到String值对应的整型数。现在,我们想想你该如何更新与某个键对应的值(简化起见,我们假设键已经存在于这个树中了):

public static void update(String k, int newval, Tree t) {

if (t == null) { /* 应增加一个新的节点 */ }

else if (k.equals(t.key)) t.val = newval;

else update(k, newval, k.compareTo(t.key) < 0 ? t.left : t.right);

}

对这个例子,增加一个新的节点会复杂很多;最简单的方法是让update直接返回它刚遍历的树(除非你需要加入一个新的节点,否则返回的树结构是不变的)。现在,这段代码看起来已经有些臃肿了(因为update试图对树进行原地更新,它返回的是跟传入的参数同样的树,但是如果最初的树为空,那么新的节点会作为结果返回)。

public static Tree update(String k, int newval, Tree t) {

if (t == null)

t = new Tree(k, newval, null, null);

else if (k.equals(t.key))

t.val = newval;

else if (k.compareTo(t.key) < 0)

t.left = update(k, newval, t.left);

else

t.right = update(k, newval, t.right);

return t;

}

注意,这两个版本的update都会对现有的树进行修改,这意味着使用树存放映射关系的所有用户都会感知到这些修改。

采用函数式的方法

如何通过函数式的方法解决呢?你需要为新的键-值对创建一个新的节点,除此之外你还需要创建从树的根节点到新节点的路径上的所有节点。通常而言,这种操作的代价并不太大,如果树的深度为d,并且保持一定的平衡性,那么这棵树的节点总数是2^d,这样你就只需要重新创建树的一小部分节点了。

public static Tree fupdate(String k, int newval, Tree t) {

return (t == null) ?

new Tree(k, newval, null, null) :

k.equals(t.key) ?

new Tree(k, newval, t.left, t.right) :

k.compareTo(t.key) < 0 ?

new Tree(t.key, t.val, fupdate(k,newval, t.left), t.right) :

new Tree(t.key, t.val, t.left, fupdate(k,newval, t.right));

}

这段代码中,我们通过一行语句进行的条件判断,没有采用if-then-else这种方式,目的是希望强调一个思想,那就是该函数体仅包含一条语句,没有任何副作用。不过你也可以按照自己的习惯,使用if-then-else这种方式,在每一个判断结束处使用return返回。

那么,update 和fupdate之间的区别到底是什么呢?

我们注意到,前文中方法update有这样一种假设,即每一个update的用户都希望共享同一份数据结构,也希望能了解程序任何部分所做的更新。因此,无论任何时候,只要你使用非函数式代码向树中添加某种形式的数据结构,请立刻创建它的一份副本,因为谁也不知道将来的某一天,某个人会突然对它进行修改,这一点非常重要(不过也经常被忽视)。

与之相反,fupdate是纯函数式的。它会创建一个新的树,并将其作为结果返回,通过参数的方式实现共享。下图对这一思想进行了阐释。你使用了一个树结构,树的每个节点包含了person对象的姓名和年龄。调用fupdate不会修改现存的树,它会在原有树的一侧创建新的节点,同时保证不损坏现有的数据结构。

这种函数式数据结构通常被称为持久化的——数据结构的值始终保持一致,不受其他部分变化的影响——这样,作为程序员的你才能确保fupdate不会对作为参数传入的数据结构进行修改。不过要达到这一效果还有一个附加条件:这个约定的另一面是,所有使用持久化数据结构的用户都必须遵守这一“不修改”原则。如果不这样,忽视这一原则的程序员很有可能修改fupdate的结果(比如,修改Emily的年纪为20岁)。这会成为一个例外(也是我们不期望发生的)事件,为所有使用该结构的方法感知,并在之后修改作为参数传递给fupdate的数据结构。

通过这些介绍,我们了解到fupdate可能有更加高效的方式:基于“不对现存结构进行修改”规则,对仅有细微差别的数据结构(比如,用户A看到的树结构与用户B看到的就相差不多),我们可以考虑对这些通用数据结构使用共享存储。你可以凭借编译器,将Tree类的字段key、val、left以及right声明为final执行,“禁止对现存数据结构的修改”这一规则;不过我们也需要注意final只能应用于类的字段,无法应用于它指向的对象,如果你想要对对象进行保护,你需要将其中的字段声明为final,以此类推。


你可能会说:“我希望对树结构的更新对某些用户可见(当然,这句话的潜台词是其他人看不到这些更新)。”那么,要实现这一目标,你可以通过两种方式:

  1. 第一种是典型的Java解决方案(对对象进行更新时,你需要特别小心,慎重地考虑是否需要在改动之前保存对象的一份副本)。

  2. 另一种是函数式的解决方案:逻辑上,你在做任何改动之前都会创建一份新的数据结构(这样一来就不会有任何的对象发生变更),只要确保按照用户的需求传递给他正确版本的数据结构就好了。

这一想法甚至还可以通过API直接强制实施。如果数据结构的某些用户需要进行可见性的改动,它们应该调用API,返回最新版的数据结构。对于另一些客户应用,它们不希望发生任何可见的改动(比如,需要长时间运行的统计分析程序),就直接使用它们保存的备份,因为它知道这些数据不会被其他程序修改。

有些人可能会说这个过程很像更新刻录光盘上的文件,刻录光盘时,一个文件只能被激光写入一次,该文件的各个版本分别被存储在光盘的各个位置(智能光盘编辑软件甚至会共享多个不同版本之间的相同部分),你可以通过传递文件起始位置对应的块地址(或者名字中编码了版本信息的文件名)选择你希望使用哪个版本的文件。Java中,情况甚至比刻录光盘还好很多,不再使用的老旧数据结构会被Java虚拟机自动垃圾回收掉。

Stream的延迟计算


自定义Stream

理解递归式Stream的思想

public static Stream primes(int n) {

return Stream.iterate(2, i -> i + 1)

.filter(MyMathUtils::isPrime)

.limit(n);

}

public static boolean isPrime(int candidate) {

int candidateRoot = (int) Math.sqrt((double) candidate);

return IntStream.rangeClosed(2, candidateRoot)

.noneMatch(i -> candidate % i == 0);

}

不过这一方案看起来有些笨拙:你每次都需要遍历每个数字,查看它能否被候选数字整除(实际上,你只需要测试那些已经被判定为质数的数字)。

理想情况下,Stream应该实时地筛选掉那些能被质数整除的数字。这听起来有些异想天开,不过我们一起看看怎样才能达到这样的效果。

  1. 你需要一个由数字构成的Stream,你会在其中选择质数。

  2. 你会从该Stream中取出第一个数字(即Stream的首元素),它是一个质数(初始时,这个值是2)。

  3. 紧接着你会从Stream的尾部开始,筛选掉所有能被该数字整除的元素。

  4. 最后剩下的结果就是新的Stream,你会继续用它进行质数的查找。本质上,你还会回到第一步,继续进行后续的操作,所以这个算法是递归的。

第一步:构造由数字组成的Stream

static Intstream numbers(){

return IntStream.iterate(2, n -> n + 1);

}

第二步:取得首元素

static int head(IntStream numbers){

return numbers.findFirst().getAsInt();

}

第三步:对尾部元素进行筛选

static IntStream tail(IntStream numbers){

return numbers.skip(1);

}

IntStream numbers = numbers();

int head = head(numbers);

IntStream filtered = tail(numbers).filter(n -> n % head != 0);

第四步:递归地创建由质数组成的Stream

static IntStream primes(IntStream numbers) {

int head = head(numbers);

return IntStream.concat(

IntStream.of(head),

primes(tail(numbers).filter(n -> n % head != 0))

);

}

坏消息

不幸的是,如果执行步骤四中的代码,你会遭遇如下这个错误:“java.lang.IllegalStateException:stream has already been operated upon or closed.”实际上,你正试图使用两个终端操作:findFirst和skip将Stream切分成头尾两部分。一旦你对Stream执行一次终端操作调用,它就永久地终止了!

延迟计算

除此之外,该操作还附带着一个更为严重的问题: 静态方法IntStream.concat接受两个Stream实例作参数。但是,由于第二个参数是primes方法的直接递归调用,最终会导致出现无限递归的状况。然而,对大多数的Java应用而言,Java 8在Stream上的这一限制,即“不允许递归定义”是完全没有影响的,使用Stream后,数据库的查询更加直观了,程序还具备了并发的能力。

所以,Java 8的设计者们进行了很好的平衡,选择了这一皆大欢喜的方案。不过,Scala和Haskell这样的函数式语言中Stream所具备的通用特性和模型仍然是你编程武器库中非常有益的补充。你需要一种方法推迟primes中对concat的第二个参数计算。如果用更加技术性的程序设计术语来描述,我们称之为延迟计算、非限制式计算或者名调用

Scala(提供了对这种算法的支持。在Scala中,你可以用下面的方式重写前面的代码,操作符#::实现了延迟连接的功能(只有在你实际需要使用Stream时才对其进行计算):

def numbers(n: Int): Stream[Int] = n #:: numbers(n+1)

def primes(numbers: Stream[Int]): Stream[Int] = {

numbers.head #:: primes(numbers.tail filter (n -> n % numbers.head != 0))

}

看不懂这段代码?完全没关系。我们展示这段代码的目的只是希望能让你了解Java和其他的函数式编程语言的区别。

在Java语言中,你执行一次方法调用时,传递的所有参数在第一时间会被立即计算出来。

但是,在Scala中,通过#::操作符,连接操作会立刻返回,而元素的计算会推迟到实际计算需要的时候才开始。我们需要通过Java实现延迟列表。

创建你自己的延迟列表

LazyLists

Java 8的Stream以其延迟性而著称。它们被刻意设计成这样,即延迟操作,有其独特的原因:Stream就像是一个黑盒,它接收请求生成结果。当你向一个 Stream发起一系列的操作请求时,这些请求只是被一一保存起来。只有当你向Stream发起一个终端操作时,才会实际地进行计算。

这种设计具有显著的优点,特别是你需要对Stream进行多个操作时(你有可能先要进行filter操作,紧接着做一个map,最后进行一次终端操作reduce);这种方式下Stream只需要遍历一次,不需要为每个操作遍历一次所有的元素。

延迟列表,它是一种更加通用的Stream形式(延迟列表构造了一个跟Stream非常类似的概念)。延迟列表同时还提供了一种极好的方式去理解高阶函数;你可以将一个函数作为值放置到某个数据结构中,大多数时候它就静静地待在那里,一旦对其进行调用(即根据需要),它能够创建更多的数据结构。

下面解释了这一思想。

一个基本的链接列表

interface MyList {

T head();

MyList tail();

default boolean isEmpty() {

return true;

}

MyList filter(Predicate p);

}

static class MyLinkedList implements MyList {

final T head;

final MyList tail;

public MyLinkedList(T head, MyList tail) {

this.head = head;

this.tail = tail;

}

public T head() {

return head;

}

public MyList tail() {

return tail;

}

public boolean isEmpty() {

return false;

}

public MyList filter(Predicate p) {

return isEmpty() ? this :

p.test(head()) ? new MyLinkedList<>(head(), tail().filter§) : tail().filter§;

}

}

static class Empty implements MyList {

public T head() {

throw new UnsupportedOperationException();

}

public MyList tail() {

throw new UnsupportedOperationException();

}

public MyList filter(Predicate p) {

return this;

}

}

可以构造一个示例的MyLinkedList值

MyList l = new MyLinkedList<>(5, new MyLinkedList<>(10, new Empty()));

一个基础的延迟列表

static class LazyList implements MyList {

final T head;

final Supplier<MyList> tail;

public LazyList(T head, Supplier<MyList> tail) {

this.head = head;

this.tail = tail;

}

public T head() {

return head;

}

public MyList tail() {

return tail.get();

}

public boolean isEmpty() {

return false;

}

public MyList filter(Predicate p) {

return isEmpty() ? this
p.test(head()) ? new LazyList<>(head(), () -> tail().filter§) : tail().filter§;

}

}

可以像下面那样传递一个Supplier作为LazyList的构造器的tail参数,创建由数字构成的无限延迟列表了,该方法会创建一系列数字中的下一个元素:

public static LazyList from(int n) {

return new LazyList(n, () -> from(n+1));

}

下面的代码执行会打印输出“2 3 4”

LazyList numbers = from(2);

int two = numbers.head();

int three = numbers.tail().head();

int four = numbers.tail().tail().head();

System.out.println(two + " " + three + " " + four);

回到生成质数

public static MyList primes(MyList numbers) {

return new LazyList<>(numbers.head(),

() -> primes(numbers.tail().filter(n -> n % numbers.head() != 0)));

}

实现一个延迟筛选器

public MyList filter(Predicate p) {

return isEmpty() ? this :

p.test(head()) ? new LazyList<>(head(), () -> tail().filter§) : tail().filter§;

}

你可以计算出头三个质数:

numbers = from(2);

int prime_two = primes(numbers).head();

int prime_three = primes(numbers).tail().head();

int prime_five = primes(numbers).tail().tail().head();

System.out.println(prime_two + " " + prime_three + " " + prime_five);

可以打印输出所有的质数

static void printAll(MyList numbers) {

if (numbers.isEmpty()) {

return;

}

System.out.println(numbers.head());

printAll(numbers.tail());

}

这个程序不会永久地运行下去;它最终会由于栈溢出而失效,

何时使用

哪些实际的场景可以使用这些技术呢?好吧,你已经了解了如何向数据结构中插入函数(因为Java 8允许你这么做),这些函数可以用于按需创建数据结构的一部分,现在你不需要在创建数据结构时就一次性地定义所有的部分。

如果你在编写游戏程序,比如棋牌类游戏,你可以定义一个数据结构,它在形式上涵盖了由所有可能移动构成的一个树(这些步骤要在早期完成计算工作量太大),具体的内容可以在运行时创建。最终的结果是一个延迟树,而不是一个延迟列表。关注延迟列表,原因是它可以和Java 8的另一个新特性Stream串接起来,我们能够针对性地讨论Stream和延迟列表各自的优缺点。


还有一个问题就是性能。我们很容易得出结论,延迟操作的性能会比提前操作要好——仅在程序需要时才计算值和数据结构当然比传统方式下一次性地创建所有的值(有时甚至比实际需求更多的值)要好。不过,实际情况并非如此简单。完成延迟操作的开销,比如 LazyList中每个元素之间执行额外Suppliers调用的开销,有可能超过你猜测会带来的好处,除非你仅仅只访问整个数据结构的10%,甚至更少。

最后,还有一种微妙的方式会导致你的LazyList并非真正的延迟计算。如果你遍历LazyList中的值,比如from(2),可能直到第10个元素,这种方式下,它会创建每个节点两次,最终创建20个节点,而不是10个。这几乎不能被称为延迟计算。问题在于每次实时访问LazyList的元素时,tail中的Supplier都会被重复调用;你可以设定tail中的Supplier方法仅在第一次实时访问时才执行调用,从而修复这一问题——计算的结果会缓存起来——效果上对列表进行了增强。要实现这一目标,你可以在LazyList的定义中添加一个私有的Optional<LazyList>类型字段alreadyComputed,tail方法会依据情况查询及更新该字段的值。纯函数式语言Haskell就是以这种方式确保它所有的数据结构都恰当地进行了延迟。


我们推荐的原则是将延迟数据结构作为你编程兵器库中的强力武器。如果它们能让程序设计更简单,就尽量使用它们。如果它们会带来无法接受的性能损失,就尝试以更加传统的方式重新实现它们。

模式匹配


PatternMatching

函数式编程中还有另一个重要的方面,那就是(结构式)模式匹配。不要将这个概念和正则表达式中的模式匹配相混淆

f(0) = 1

f(n) = n*f(n-1) otherwise

不过在Java语言中,你只能通过if-then-else语句或者switch语句实现。随着数据类型变得愈加复杂,需要处理的代码(以及代码块)的数量也在迅速攀升。使用模式匹配能有效地减少这种混乱的情况。

为了说明,我们先看一个树结构,你希望能够遍历这一整棵树。我们假设使用一种简单的数学语言,它包含数字和二进制操作符:

class Expr { … }

class Number extends Expr { int val; … }

class BinOp extends Expr { String opname; Expr left, right; … }

假设你需要编写方法简化一些表达式。比如,5 + 0可以简化为5。使用我们的域语言,new BinOp(“+”, new Number(5), new Number(0))可以简化为Number(5)。你可以像下面这样遍历Expr结构:

Expr simplifyExpression(Expr expr) {

if (expr instanceof BinOp

&& ((BinOp)expr).opname.equals(“+”))

&& ((BinOp)expr).right instanceof Number

&& … // 变得非常笨拙

&& … ) {

return (Binop)expr.left;

}

}

你可以预期这种方式下代码会迅速地变得异常丑陋,难于维护

访问者设计模式

Java语言中还有另一种方式可以解包数据类型,那就是使用访问者(Visitor)设计模式。本质上,使用这种方法你需要创建一个单独的类,这个类封装了一个算法,可以“访问”某种数据类型。

它是如何工作的呢?访问者类接受某种数据类型的实例作为输入。它可以访问该实例的所有成员。下面是一个例子,通过这个例子我们能了解这一方法是如何工作的。首先,你需要向BinOp添加一个accept方法,它接受一个SimplifyExprVisitor作为参数,并将自身传递给它(你还需要为Number添加一个类似的方法):

class BinOp extends Expr{

public Expr accept(SimplifyExprVisitor v){

return v.visit(this);

}

}

SimplifyExprVisitor现在就可以访问BinOp对象并解包其中的内容了:

public class SimplifyExprVisitor {

public Expr visit(BinOp e){

if(“+”.equals(e.opname) && e.right instanceof Number && …){

return e.left;

}

return e;

}

}

用模式匹配力挽狂澜

通过一个名为模式匹配的特性,我们能以更简单的方案解决问题。这种特性目前在Java语言中暂时还不提供,所以我们会以Scala程序设计语言的一个小例子来展示模式匹配的强大威力。

假设数据类型Expr代表的是某种数学表达式,在Scala程序设计语言中(我们采用Scala的原因是它的语法与Java非常接近),你可以利用下面的这段代码解析表达式:

def simplifyExpression(expr: Expr): Expr = expr match {

case BinOp(“+”, e, Number(0)) => e // 加0

case BinOp(“*”, e, Number(1)) => e // 乘以1

case BinOp(“/”, e, Number(1)) => e // 除以1

case _ => expr // 不能简化expr

}

模式匹配为操纵类树型数据结构提供了一个极其详细又极富表现力的方式。构建编译器或者处理业务规则的引擎时,这一工具尤其有用。注意,Scala的语法

Expression match { case Pattern => Expression … }

和Java的语法非常相似:

switch (Expression) { case Constant : Statement … }

Scala的通配符判断和Java中的default:扮演这同样的角色。这二者之间主要的语法区别在于Scala是面向表达式的,而Java则更多地面向语句,不过,对程序员而言,它们主要的区别是Java中模式的判断标签被限制在了某些基础类型、枚举类型、封装基础类型的类以及String类型。

使用支持模式匹配的语言实践中能带来的最大的好处在于,你可以避免出现大量嵌套的switch或者if-then-else语句和字段选择操作相互交织的情况。

非常明显,Scala的模式匹配在表达的难易程度上比Java更胜一筹,你只能期待未来版本的Java能支持更具表达性的switch语句。

与此同时,让我们看看如何凭借Java 8的Lambda以另一种方式在Java中实现类模式匹配。

我们在这里介绍这一技巧的目的仅仅是想让你了解Lambda另一个有趣的应用。

Java中的伪模式匹配

首先,让我们看看Scala的模式匹配特性提供的匹配表达式有多么丰富。比如下面这个例子:

def simplifyExpression(expr: Expr): Expr = expr match {

case BinOp(“+”, e, Number(0)) => e

它表达的意思是:“检查expr是否为BinOp,抽取它的三个组成部分(opname、left、right),紧接着对这些组成部分分别进行模式匹配——第一个部分匹配String+,第二个部分匹配变量e(它总是匹配),第三个部分匹配模式Number(0)。”

换句话说,Scala(以及很多其他的函数式语言)中的模式匹配是多层次的。我们使用Java 8的Lambda表达式进行的模式匹配模拟只会提供一层的模式匹配;以前面的这个例子而言,这意味着它只能覆盖BinOp(op, l, r)或者Number(n)这种用例,无法顾及BinOp(“+”, e, Number(0))。

首先,我们做一些稍微让人惊讶的观察。由于你选择使用Lambda,原则上你的代码里不应该使用if-then-else。你可以使用方法调用

myIf(condition, () -> e1, () -> e2);

取代condition ? e1 : e2这样的代码。

在某些地方,比如库文件中,你可能有这样的定义(使用了通用类型T):

static T myIf(boolean b, Supplier truecase, Supplier falsecase) {

return b ? truecase.get() : falsecase.get();

}

类型T扮演了条件表达式中结果类型的角色。原则上,你可以用if-then-else完成类似的事儿。

当然,正常情况下用这种方式会增加代码的复杂度,让它变得愈加晦涩难懂,因为用if-then-else就已经能非常顺畅地完成这一任务,这么做似乎有些杀鸡用牛刀的嫌疑。不过,我们也注意到,Java的switch和if-then-else无法完全实现模式匹配的思想,而Lambda表达式能以简单的方式实现单层的模式匹配——对照使用if-then-else链的解决方案,这种方式要简洁得多。

回来继续讨论类Expr的模式匹配值,Expr类有两个子类,分别为BinOp和Number,你可以定义一个方法patternMatchExpr(同样,我们在这里会使用泛型T,用它表示模式匹配的结果类型):

interface TriFunction<S, T, U, R>{

R apply(S s, T t, U u);

}

static T patternMatchExpr(

Expr e,

TriFunction<String, Expr, Expr, T> binopcase,

Function<Integer, T> numcase,

Supplier defaultcase) {

return

(e instanceof BinOp) ?

binopcase.apply(((BinOp)e).opname, ((BinOp)e).left,

((BinOp)e).right) :

(e instanceof Number) ?

numcase.apply(((Number)e).val) :

defaultcase.get();

}

最终的结果是,方法调用

patternMatchExpr(e, (op, l, r) -> {return binopcode;},

(n) -> {return numcode;},

() -> {return defaultcode;});

会判断e是否为BinOp类型(如果是,会执行binopcode方法,它能够通过标识符op、l和r访问BinOp的字段),是否为Number类型(如果是,会执行numcode方法,它可以访问n的值)。这个方法还可以返回defaultcode,如果有人在将来某个时刻创建了一个树节点,它既不是BinOp类型,也不是Number类型,那就会执行这部分代码。

下面这段代码通过简化的加法和乘法表达式展示了如何使用patternMatchExpr。

private static void simplify() {

TriFunction<String, Expr, Expr, Expr> binopcase =

(opname, left, right) -> {

if (“+”.equals(opname)) {

if (left instanceof Number && ((Number) left).val == 0) {

return right;

}

if (right instanceof Number && ((Number) right).val == 0) {

return left;

}

}

if (“*”.equals(opname)) {

if (left instanceof Number && ((Number) left).val == 1) {

return right;

}

if (right instanceof Number && ((Number) right).val == 1) {

return left;

}

}

return new BinOp(opname, left, right);

};

Function<Integer, Expr> numcase = val -> new Number(val);

Supplier defaultcase = () -> new Number(0);

Expr e = new BinOp(“+”, new Number(5), new Number(0));

Expr match = patternMatchExpr(e, binopcase, numcase, defaultcase);

if (match instanceof Number) {

System.out.println("Number: " + match);

} else if (match instanceof BinOp) {

System.out.println("BinOp: " + match);

}

}

你可以通过下面的方式调用简化的方法:

Expr e = new BinOp(“+”, new Number(5), new Number(0));

Expr match = simplify(e);

System.out.println(match);

杂项


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

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

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

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

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

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

img
线程、数据库、算法、JVM、分布式、微服务、框架、Spring相关知识

一线互联网P7面试集锦+各种大厂面试集锦

学习笔记以及面试真题解析

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

}

}

你可以通过下面的方式调用简化的方法:

Expr e = new BinOp(“+”, new Number(5), new Number(0));

Expr match = simplify(e);

System.out.println(match);

杂项


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

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

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

[外链图片转存中…(img-ilmN4tBy-1713383120136)]

[外链图片转存中…(img-IvCc2utj-1713383120136)]

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

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

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

img
线程、数据库、算法、JVM、分布式、微服务、框架、Spring相关知识

[外链图片转存中…(img-MRTy2gBl-1713383120137)]

一线互联网P7面试集锦+各种大厂面试集锦

[外链图片转存中…(img-edFZgA4C-1713383120137)]

学习笔记以及面试真题解析

[外链图片转存中…(img-69N9VNqB-1713383120137)]

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值