【编程语言】Scala 函数式编程

时间:2024-10-05 22:31:35

函数是Scala 中的一等公民。

本文讨论Scala函数式编程的一些基本原理。你将会学到如何使用高阶函数,以及重用已有代码时,遵守 DRY 原则。

Scala 的集合库很棒 #

So what does the above buy you anyway? The following are some examples from Scala’s own collections library.那么,以上这些到底给你带来了什么?以下是 Scala 自己的集合库中的一些示例。

You can sum things up in sequences, as long as you have an implementation of type-class Numeric[T] in scope:只要在范围内有类型类 Numeric[T] 的实现,就可以按顺序sum:

  1. List(1,2,3,4).sum
  2. //=> Int = 10

You can sort things, as long as you have an implementation of type-class Ordering[T] in scope:只要在范围内有类型类 Ordering[T] 的实现,就可以对事物进行排序:

  1. List("d", "c", "e", "a", "b").sorted
  2. //=> List[] = List(a, b, c, d, e)

A collection will always do the right thing, returning the same kind of collection when doing a map() or a flatMap() or a filter() over it. For instance to revert the keys and values of a Map:集合将始终做正确的事情,在对其执行 map() 或 flatMap() 或 filter() 时返回相同类型的集合。例如恢复 Map 的键和值:

  1. Map(1 -> 2, 3 -> 4).map{ case (k,v) => (v,k) }
  2. //=> [Int,Int] = Map(2 -> 1, 4 -> 3)

高阶函数

和一阶函数相比,高阶函数可以有三种形式:

  1. 一个或多个参数是函数,并返回一个值。

  2. 返回一个函数,但没有参数是函数。

  3. 上述两者叠加:一个或多个参数是函数,并返回一个函数。

看到这里的读者应该已经见到过第一种使用:我们调用一个方法,像 map 、 filter 、 flatMap ,并传递另一个函数给它。传递给方法的函数通常是匿名函数,有时候,还涉及一些代码冗余。

这一章只关注另外两种功能:一个可以根据输入值构建新的函数,另一个可以根据现有的函数组合出新的函数。这两种情况都能够消除代码冗余。

函数生成

你可能认为依据输入值创建新函数的能力并不是那么有用。函数组合非常重要,但在这之前,还是先来看看如何使用可以产生新函数的函数。

假设要实现一个免费的邮件服务,用户可以设置对邮件的屏蔽。我们用一个简单的样例类来代表邮件:

  1. case class Email(
  2. subject: String,
  3. text: String,
  4. sender: String,
  5. recipient: String
  6. )

想让用户可以自定义过滤条件,需有一个过滤函数——类型为 Email => Boolean 的谓词函数, 这个谓词函数决定某个邮件是否该被屏蔽:如果谓词成真,那这个邮件被接受,否则就被屏蔽掉。

  1. type EmailFilter = Email => Boolean
  2. def newMailsForUser(mails: Seq[Email], f: EmailFilter) = (f)

注意,类型别名使得代码看起来更有意义。

现在,为了使用户能够配置邮件过滤器,实现了一些可以产生 EmailFilter 的工厂方法:

  1. val sentByOneOf: Set[String] => EmailFilter =
  2. senders =>
  3. email => ()
  4. val notSentByAnyOf: Set[String] => EmailFilter =
  5. senders =>
  6. email => !()
  7. val minimumSize: Int => EmailFilter =
  8. n =>
  9. email => >= n
  10. val maximumSize: Int => EmailFilter =
  11. n =>
  12. email => <= n

这四个 vals 都是可以返回 EmailFilter 的函数, 前两个接受代表发送者的 Set[String] 作为输入,后两个接受代表邮件内容长度的 Int 作为输入。

可以使用这些函数来创建 EmialFilter :

  1. val emailFilter: EmailFilter = notSentByAnyOf(Set("johndoe@"))
  2. val mails = Email(
  3. subject = "It's me again, your stalker friend!",
  4. text = "Hello my friend! How are you?",
  5. sender = "johndoe@",
  6. recipient = "me@") :: Nil
  7. newMailsForUser(mails, emailFilter) // returns an empty list

这个过滤器过滤掉列表里唯一的一个元素,因为用户屏蔽了来自 johndoe@ 的邮件。可以用工厂方法创建任意的 EmailFilter 函数,这取决于用户的需求了。

重用已有函数

当前的解决方案有两个问题。第一个是工厂方法中有重复代码。上文提到过,函数的组合特征可以很轻易的保持 DRY 原则,既然如此,那就试着使用它吧!

对于 minimumSize 和 maximumSize ,我们引入一个叫做 sizeConstraint 的函数。这个函数接受一个谓词函数,该谓词函数检查函数内容长度是否OK,邮件长度会通过参数传递给它:

  1. type SizeChecker = Int => Boolean
  2. val sizeConstraint: SizeChecker => EmailFilter =
  3. f =>
  4. email => f()

这样,我们就可以用 sizeConstraint 来表示 minimumSize 和 maximumSize 了:

  1. val minimumSize: Int => EmailFilter =
  2. n =>
  3. sizeConstraint(_ >= n)
  4. val maximumSize: Int => EmailFilter =
  5. n =>
  6. sizeConstraint(_ <= n)

 

函数组合

为另外两个谓词(sentByOneOf、 notSentByAnyOf)介绍一个通用的高阶函数,通过它,可以用一个函数去表达另外一个函数。

这个高阶函数就是 complement ,给定一个类型为 A => Boolean 的谓词,它返回一个新函数, 这个新函数总是得出和谓词相对立的结果:

def complement[A](predicate: A => Boolean) = (a: A) => !predicate(a)

现在,对于一个已有的谓词 p ,调用 complement(p) 可以得到它的补。然而, sentByAnyOf 并不是一个谓词函数,它返回类型为 EmailFilter 的谓词。

Scala 函数的可组合能力现在就用的上了:给定两个函数 f 、 g , (g) 返回一个新函数, 调用这个新函数时,会首先调用 g ,然后应用 f 到 g 的返回结果上。类似的, (g) 返回的新函数会应用 g 到 f 的返回结果上。

知道了这些,我们就可以重写 notSentByAnyOf 了:

val notSentByAnyOf = sentByOneOf andThen (g => complement(g))

上面的代码创建了一个新的函数, 这个函数首先应用 sentByOneOf 到参数 Set[String] 上,产生一个 EmailFilter 谓词, 然后,应用 complement 到这个谓词上。使用 Scala 的下划线语法,这短代码还能更精简:

val notSentByAnyOf = sentByOneOf andThen (complement(_))

读者可能已经注意到, 给定 complement 函数,也可以通过 minimumSize 来实现 maximumSize 。不过,先前的实现方式更加灵活,它允许检查邮件内容的任意长度。 

谓词组合

邮件过滤器的第二个问题是,当前只能传递一个 EmailFilter 给 newMailsForUser 函数,而用户必然想设置多个标准。所以需要可以一种可以创建组合谓词的方法,这个组合谓词可以在任意一个标准满足的情况下返回 true ,或者在都不满足时返回 false 。

下面的代码是一种实现方式:

  1. def any[A](predicates: (A => Boolean)*): A => Boolean =
  2. a => (pred => pred(a))
  3. def none[A](predicates: (A => Boolean)*) = complement(any(predicates: _*))
  4. def every[A](predicates: (A => Boolean)*) = none(.map(complement(_)): _*)

any 函数返回的新函数会检查是否有一个谓词对于输入 a 成真。 none 返回的是 any 返回函数的补,只要存在一个成真的谓词, none 的条件就无法满足。最后, every 利用 none 和 any 来判定是否每个谓词的补对于输入 a 都不成真。

可以使用它们来创建代表用户设置的组合 EmialFilter :

  1. val filter: EmailFilter = every(
  2. notSentByAnyOf(Set("johndoe@")),
  3. minimumSize(100),
  4. maximumSize(10000)
  5. )

 

流水线组合

再举一个函数组合的例子。回顾下上面的场景, 邮件提供者不仅想让用户可以配置邮件过滤器,还想对用户发送的邮件做一些处理。这是一些简单的 Emial => Email 函数,一些可能的处理函数是:

  1. val addMissingSubject = (email: Email) =>
  2. if () email.copy(subject = "No subject")
  3. else email
  4. val checkSpelling = (email: Email) =>
  5. email.copy(text = ("your", "you're"))
  6. val removeInappropriateLanguage = (email: Email) =>
  7. email.copy(text = ("dynamic typing", "**CENSORED**"))
  8. val addAdvertismentToFooter = (email: Email) =>
  9. email.copy(text = + "\nThis mail sent via Super Awesome Free Mail")

现在,根据老板的心情,可以按需配置邮件处理的流水线。通过 andThen 调用实现,或者使用 Function 伴生对象上的 chain 方法:

  1. val pipeline = (Seq(
  2. addMissingSubject,
  3. checkSpelling,
  4. removeInappropriateLanguage,
  5. addAdvertismentToFooter))

 

高阶函数与偏函数

这部分不会关注细节,不过,在知道了这么多通过高阶函数来组合和重用函数的方法之后,你可能想再重新看看偏函数。

链接偏函数

匿名函数那一章提到过,偏函数可以被用来创建责任链: PartialFunction 上的 orElse 方法允许链接任意个偏函数,从而组合出一个新的偏函数。不过,只有在一个偏函数没有为给定输入定义的时候,才会把责任传递给下一个偏函数。从而可以做下面这样的事情:

val handler = fooHandler orElse barHandler orElse bazHandler

 

再看偏函数

有时候,偏函数并不合适。仔细想想,一个函数没有为所有的输入值定义操作,这样的事实还可以用一个返回 Option[A] 的标准函数代替:如果函数为一个输入定义了操作,那就返回 Some[A] ,否则返回 None 。

要这么做的话,可以在给定的偏函数 pf 上调用 lift 方法得到一个普通的函数,这个函数返回 Option 。反过来,如果有一个返回 Option 的普通函数 f ,也可以调用 (f) 来得到一个偏函数。总

总结

这一章给出了高阶函数的使用,利用它可以在一个新的环境里重用已有函数,并用灵活的方式去组合它们。在所举的例子中,就代码行数而言,可能看不出太多价值, 这些例子都很简单,只是为了说明而已,在架构层面,组合和重用函数是有很大帮助的。

接下来,我们继续探索函数组合的方式:函数部分应用和柯里化(Partial Function Application and Currying)

柯里化和部分函数应用

上一章重点在于代码重复:提升现有的函数功能、或者将函数进行组合。这一章,我们来看看另外两种函数重用的机制:函数的部分应用(Partial Application of Functions) 、 柯里化(Currying) 。

部分应用的函数

和其他遵循函数式编程范式的语言一样,Scala 允许部分应用一个函数。调用一个函数时,不是把函数需要的所有参数都传递给它,而是仅仅传递一部分,其他参数留空;这样会生成一个新的函数,其参数列表由那些被留空的参数组成。(不要把这个概念和偏函数混淆)

为了具体说明这一概念,回到上一章的例子:假想的免费邮件服务,能够让用户配置筛选器,以使得满足特定条件的邮件显示在收件箱里,其他的被过滤掉。

Email 类看起来仍然是这样:

  1. case class Email(
  2. subject: String,
  3. text: String,
  4. sender: String,
  5. recipient: String)
  6. type EmailFilter = Email => Boolean

过滤邮件的条件用谓词 Email => Boolean 表示, EmailFilter 是其别名。调用适当的工厂方法可以生成这些谓词。

上一章,我们创建了两个这样的工厂方法,它们检查邮件内容长度是否满足给定的最大值或最小值。这一次,我们使用部分应用函数来实现这些工厂方法,做法是,修改 sizeConstraint ,固定某些参数可以创建更具体的限制条件:

其修改后的代码如下:

  1. type IntPairPred = (Int, Int) => Boolean
  2. def sizeConstraint(pred: IntPairPred, n: Int, email: Email) =
  3. pred(, n)

上述代码为一个谓词函数定义了别名 IntPairPred ,该函数接受一对整数(值 n 和邮件内容长度),检查邮件长度对于 n 是否 OK。

请注意,不像上一章的 sizeConstraint ,这一个并不返回新的 EmailFilter,它只是简单的用参数做计算,返回一个布尔值。秘诀在于,你可以部分应用这个 sizeConstraint 来得到一个 EmailFilter 。

遵循 DRY 原则,我们先来定义常用的 IntPairPred 实例,这样,在调用 sizeConstraint 时,不需要重复的写相同的匿名函数,只需传递下面这些:

  1. val gt: IntPairPred = _ > _
  2. val ge: IntPairPred = _ >= _
  3. val lt: IntPairPred = _ < _
  4. val le: IntPairPred = _ <= _
  5. val eq: IntPairPred = _ == _

最后,调用 sizeConstraint 函数,用上面的 IntPairPred 传入第一个参数:

  1. val minimumSize: (Int, Email) => Boolean = sizeConstraint(ge, _: Int, _: Email)
  2. val maximumSize: (Int, Email) => Boolean = sizeConstraint(le, _: Int, _: Email)

对所有没有传入值的参数,必须使用占位符 _ ,还需要指定这些参数的类型,这使得函数的部分应用多少有些繁琐。Scala 编译器无法推断它们的类型,方法重载使编译器不可能知道你想使用哪个方法。

不过,你可以绑定或漏掉任意个、任意位置的参数。比如,我们可以漏掉第一个值,只传递约束值 n :

  1. val constr20: (IntPairPred, Email) => Boolean =
  2. sizeConstraint(_: IntPairPred, 20, _: Email)
  3.  
  4. val constr30: (IntPairPred, Email) => Boolean =
  5. sizeConstraint(_: IntPairPred, 30, _: Email)

得到的两个函数,接受一个 IntPairPred 和一个 Email 作为参数, 然后利用谓词函数 IntPairPred 把邮件长度和 20 、 30 比较, 只不过比较方法的逻辑 IntPairPred 需要另外指定。

由此可见,虽然函数部分应用看起来比较冗长,但它要比 Clojure 的灵活,在 Clojure 里,必须从左到右的传递参数,不能略掉中间的任何参数。

从方法到函数对象

在一个方法上做部分应用时,可以不绑定任何的参数,这样做的效果是产生一个函数对象,并且其参数列表和原方法一模一样。通过这种方式可以将方法变成一个可赋值、可传递的函数!

val sizeConstraintFn: (IntPairPred, Int, Email) => Boolean = sizeConstraint _

 

更有趣的函数

部分函数应用显得太啰嗦,用起来不够优雅,幸好还有其他的替代方法。

也许你已经知道 Scala 里的方法可以有多个参数列表。下面的代码用多个参数列表重新定义了 sizeConstraint :

  1. def sizeConstraint(pred: IntPairPred)(n: Int)(email: Email): Boolean =
  2. pred(, n)

如果把它变成一个可赋值、可传递的函数对象,它的签名看起来会像是这样:

val sizeConstraintFn: IntPairPred => Int => Email => Boolean = sizeConstraint _

这种单参数的链式函数称做 柯里化函数 ,以发明人 Haskell Curry 命名。在 Haskell 编程语言里,所有的函数默认都是柯里化的。

sizeConstraintFn 接受一个 IntPairPred ,返回一个函数,这个函数又接受 Int 类型的参数,返回另一个函数,最终的这个函数接受一个 Email ,返回布尔值。

现在,可以把要传入的 IntPairPred 传递给 sizeConstraint 得到:

  1. val minSize: Int => Email => Boolean = sizeConstraint(ge)
  2. val maxSize: Int => Email => Boolean = sizeConstraint(le)

被留空的参数没必要使用占位符,因为这不是部分函数应用。

现在,可以通过这两个柯里化函数来创建 EmailFilter 谓词:

  1. val min20: Email => Boolean = minSize(20)
  2. val max20: Email => Boolean = maxSize(20)

也可以在柯里化的函数上一次性绑定多个参数,直接得到上面的结果。传入第一个参数得到的函数会立即应用到第二个参数上:

  1. val min20: Email => Boolean = sizeConstraintFn(ge)(20)
  2. val max20: Email => Boolean = sizeConstraintFn(le)(20)

 

函数柯里化

有时候,并不总是能提前知道要不要将一个函数写成柯里化形式,毕竟,和只有单参数列表的函数相比,柯里化函数的使用并不清晰。而且,偶尔还会想以柯里化的形式去使用第三方的函数,但这些函数的参数都在一个参数列表里。

这就需要一种方法能对函数进行柯里化。这种的柯里化行为本质上也是一个高阶函数:接受现有的函数,返回新函数。这个高阶函数就是 curried :curried 方法存在于 Function2 、 Function3 这样的多参数函数类型里。如果存在一个接受两个参数的 sum ,可以通过调用 curried 方法得到它的柯里化版本:

  1. val sum: (Int, Int) => Int = _ + _
  2. val sumCurried: Int => Int => Int =

使用  进行反向操作,可以将一个柯里化函数转换成非柯里化版本。

函数化的依赖注入

在这一章的最后,我们来看看柯里化函数如何发挥其更大的作用。来自 Java 或者 .NET 世界的人,或多或少都用过依赖注入容器,这些容器为使用者管理对象,以及对象之间的依赖关系。在 Scala 里,你并不真的需要这样的外部工具,语言已经提供了许多功能,这些功能简化了依赖注入的实现。

函数式编程仍然需要注入依赖:应用程序中上层函数需要调用其他函数。把要调用的函数硬编码在上层函数里,不利于它们的独立测试。从而需要把被依赖的函数以参数的形式传递给上层函数。

但是,每次调用都传递相同的依赖,是不符合 DRY 原则的,这时候,柯里化函数就有用了!柯里化和部分函数应用是函数式编程里依赖注入的几种方式之一。

下面这个简化的例子说明了这项技术:

  1. case class User(name: String)
  2. trait EmailRepository {
  3. def getMails(user: User, unread: Boolean): Seq[Email]
  4. }
  5. trait FilterRepository {
  6. def getEmailFilter(user: User): EmailFilter
  7. }
  8. trait MailboxService {
  9. def getNewMails(emailRepo: EmailRepository)(filterRepo: FilterRepository)(user: User) =
  10. (user, true).filter((user))
  11. val newMails: User => Seq[Email]
  12. }

这个例子有一个依赖两个不同存储库的服务,这些依赖被声明为 getNewMails 方法的参数,并且每个依赖都在一个单独的参数列表里。

MailboxService 实现了这个方法,留空了字段 newMails,这个字段的类型是一个函数: User => Seq[Email],依赖于 MailboxService 的组件会调用这个函数。

扩展 MailboxService 时,实现 newMails 的方法就是应用 getNewMails 这个方法,把依赖 EmailRepository 、 FilterRepository 的具体实现传递给它:

  1. object MockEmailRepository extends EmailRepository {
  2. def getMails(user: User, unread: Boolean): Seq[Email] = Nil
  3. }
  4. object MockFilterRepository extends FilterRepository {
  5. def getEmailFilter(user: User): EmailFilter = _ => true
  6. }
  7. object MailboxServiceWithMockDeps extends MailboxService {
  8. val newMails: (User) => Seq[Email] =
  9. getNewMails(MockEmailRepository)(MockFilterRepository) _
  10. }

调用 (User("daniel") 无需指定要使用的存储库。在实际的应用程序中,这个服务也可能是以依赖的方式被使用,而不是直接引用。

这可能不是最强大、可扩展的依赖注入实现方式,但依旧是一个非常不错的选择,对展示部分函数应用和柯里化更广泛的功用来说,这也是一个不错的例子。如果你想知道更多关于这一点的知识,推荐看 Debasish Ghosh 的幻灯片 “Dependency Injection in Scala”。

总结

本文讨论了几种保持 DRY 和灵活性的函数式编程技术:

  1. 函数组合(function composition)

  2. 部分函数应用(partial function application)

  3. 柯里化(currying)


 
  1. 【更多阅读】【编程语言】AWK 极简教程
  2. Bito AI:免费使用 ChatGPT 编写代码/修复错误/创建测试用例Use ChatGPT to 10x dev work
  3. 写代码犹如写文章: “大师级程序员把系统当故事来讲,而不是当做程序来写” | 如何架构设计复杂业务系统? 如何写复杂业务代码?
  4. 【工作10年+的大厂资深架构师万字长文总结 精华收藏!】怎样设计高可用、高性能系统?关于高可用高性能系统架构和设计理论和经验总结
  5. 【企业架构设计实战】0 企业数字化转型和升级:架构设计方法与实践  
  6. 【企业架构设计实战】1 企业架构方法论
  7. 【企业架构设计实战】2 业务架构设计
  8. 【企业架构设计实战】3 怎样进行系统逻辑架构?
  9. 【企业架构设计实战】4 应用架构设计
  10. 【企业架构设计实战】5 大数据架构设计
  11. 【企业架构设计实战】6 数据架构
  12. 企业数字化转型和升级:架构设计方法与实践
  13. 【成为架构师课程系列】怎样进行系统逻辑架构?
  14. 【成为架构师课程系列】怎样进行系统详细架构设计?
  15. 【企业架构设计实战】企业架构方法论
  16. 【企业架构设计实战】业务架构设计【企业架构设计实战】应用架构设计【企业架构设计实战】大数据架构设计【软件架构思想系列】分层架构【软件架构思想系列】模块化与抽象软件架构设计的核心:抽象与模型、“战略编程”企业级大数据架构设计最佳实践编程语言:类型系统的本质程序员架构修炼之道:软件架构设计的37个一般性原则程序员架构修炼之道:如何设计“易理解”的系统架构?“封号斗罗” 程序员修炼之道:通向务实的最高境界程序员架构修炼之道:架构设计中的人文主义哲学
  17. Gartner  2023  年*战略技术趋势【软件架构思想系列】从伟人《矛盾论》中悟到的软件架构思想真谛:“对象”即事物,“函数”即运动变化【模型↔关系思考法】如何在一个全新的、陌生的领域快速成为专家?模仿 + 一万小时定律 + 创新
  18. Redis 作者 Antirez 讲如何实现分布式锁?Redis 实现分布式锁天然的缺陷分析&Redis分布式锁的正确使用姿势!
  19. 红黑树、B树、B+树各自适用的场景
  20. 你真的懂树吗?二叉树、AVL平衡二叉树、伸展树、B-树和B+树原理和实现代码详解
  21. 【动态图文详解-史上最易懂的红黑树讲解】手写红黑树(Red Black Tree)
  22. 我的年度用户体验趋势报告——由 ChatGPT AI 撰写
  23. 我面试了 ChatGPT 的 PM (产品经理)岗位,它几乎得到了这份工作!!!
  24. 大数据存储引擎 NoSQL极简教程 An Introduction to Big Data: NoSQL《人月神话》(The Mythical Man-Month)看清问题的本质:如果我们想解决问题,就必须试图先去理解它【架构师必知必会】常见的NoSQL数据库种类以及使用场景新时期我国信息技术产业的发展【技术论文,纪念长者,2008】B-树(B-Tree)与二叉搜索树(BST):讲讲数据库和文件系统背后的原理(读写比较大块数据的存储系统数据结构与算法原理)HBase 架构详解及数据读写流程【架构师必知必会系列】系统架构设计需要知道的5大精要(5 System Design fundamentals)《人月神话》8 胸有成竹(Chaptor 8.Calling the Shot -The Mythical Man-Month)《人月神话》7(The Mythical Man-Month)为什么巴比伦塔会失败?《人月神话》(The Mythical Man-Month)6贯彻执行(Passing the Word)《人月神话》(The Mythical Man-Month)5画蛇添足(The Second-System Effect)《人月神话》(The Mythical Man-Month)4概念一致性:专制、*和系统设计(System Design)《人月神话》(The Mythical Man-Month)3 外科手术队伍(The Surgical Team)《人月神话》(The Mythical Man-Month)2人和月可以互换吗?人月神话存在吗?在平时的工作中如何体现你的技术深度?Redis 作者 Antirez 讲如何实现分布式锁?Redis 实现分布式锁天然的缺陷分析&Redis分布式锁的正确使用姿势!程序员职业生涯系列:关于技术能力的思考与总结十年技术进阶路:让我明白了三件要事。关于如何做好技术 Team Leader?如何提升管理业务技术水平?(10000字长文)当你工作几年就会明白,以下几个任何一个都可以超过90%程序员编程语言:类型系统的本质软件架构设计的核心:抽象与模型、“战略编程”【图文详解】深入理解 Hbase 架构  Deep Into HBase ArchitectureHBase 架构详解及读写流程原理剖析HDFS 底层交互原理,看这篇就够了!MySQL 体系架构简介一文看懂MySQL的异步复制、全同步复制与半同步复制【史上最全】MySQL各种锁详解:一文搞懂MySQL的各种锁腾讯/阿里/字节/快手/美团/百度/京东/网易互联网大厂面试题库Redis 面试题 50 问,史上最全。一道有难度的经典大厂面试题:如何快速判断某 URL 是否在 20 亿的网址 URL 集合中?【BAT 面试题宝库附详尽答案解析】图解分布式一致性协议 Paxos 算法Java并发多线程高频面试题编程实践系列: 字节跳动面试题腾讯/阿里/字节/快手/美团/百度/京东/网易互联网大厂面试题库
  25. [精华集锦] 20+ 互联网大厂Java面试题全面整理总结
  26. 【BAT 面试题宝库附详尽答案解析】分布式事务实现原理……

相关文章