发布于 2026-01-06 5 阅读
0

以 Clojure 思维方式学习 Java 课程文字稿 Clojure 函数式编程 vs 面向对象编程 动态类型(vs 静态类型) 动态开发(vs 静态开发) Lisp(vs Fortran) 总结

用Clojure思维方式开发Java

文字稿

Clojure

函数式编程与面向对象编程

动态(与静态)类型

动态(与静态)开发

Lisp(对比 Fortran)

概括

本次演讲将以一个真实的应用程序为例,学习如何仅使用核心 Java 构建和设计遵循 Clojure 函数式原则的 Java 应用程序,而无需任何库、lambda、流或奇怪的语法;我们还将了解这些函数式原则能够带来哪些好处。

先放个预告:

这里提供Keynote 格式PPT 格式(未经测试!)SlideShare 格式(不知何故损坏了!)的幻灯片

还有这段视频:

欢迎提出意见和问题!


文字稿

我们先来考考你。谁能告诉我这是什么?

外星人

这就是你第一次看到 Clojure 时的样子。

我想指出这张图片中的三个重要之处。

首先,它非常丑陋,有些人甚至会说它令人作呕。

其次,有很多事情完全说不通。例如,你为什么要把虫子放在嘴里?

外星人

或者说,那到底是什么东西?它在那里做什么?

外星人

但最后,也是最重要的一点,它是外来的。当我们看待这幅图景时,我们是从人类的角度,从我们爪哇人的视角来看待它的。

所以也许我们并不公平,而且随着时间的推移,

下蟾蜍

再加上下蟾蜍的帮助,或许那幅图会是这样的:

外星人

因为理解会带来视角上的改变,从而使我们能够做出更公正的判断。

我知道你在想什么,神奇女侠严格来说不算外星人,而且如果我们在讨论Clojure,那图片就缺失了。

外星人

一些括号。

但是,如果我们花些时间研究这些外星人,也许我们会发现一些超能力,可以在我们作为 Java 开发人员的日常工作中加以利用。

Clojure

那么,Clojure是什么呢?Clojure是一种用于JVM的函数式、托管式、动态且强类型的Lisp语言。

Clojure

所以,在本次课程中,我们将以一个真实世界的应用程序为例,看看 Clojure 是如何影响我们构建和设计这个 Java 应用程序的方式,以及 Clojure 是如何影响我们的 Java 代码的。

为了提供更多背景信息,当我开始参与这个应用程序的开发时,

维库斯

我当时已经有 12 年的 Java 开发经验和 3 年的 Clojure 开发经验,所以当我参与其中时,我已经达到了 Wikus 旅程的第二阶段,我的转变已经达到了一个完全的外星人思维。

我们今天要讨论的这款应用,是博彩行业中常见的一种奖励系统。

奖金

诸如“如果你往账户里存点钱,我们会免费送你双倍金额”或“如果你现在加入我们,我们会送你1000欧元现金!”之类的广告。

现在没有人会白白给你现金,所以如果你阅读条款和条件,就会发现,要想有资格提取那笔“免费”现金,要想拿到那笔现金并放进口袋,你必须先在系统中玩或下注几次,或者进行一些活动。

另外,就我们这个特殊案例而言(我不确定这是否常见),客户下注的时间有限。

抛开所有营销噱头,我们的应用程序实际需要实现的功能大致如下:

如果客户注册了奖金,并且在一定时间内下了很多注,则向客户账户添加一定金额的资金。

因此,从实施的角度来看,该系统必须知道哪些奖金可用,哪些客户注册了这些奖金,然后跟踪这些客户的有趣活动,在我们的例子中,就是下注和存款。

函数式编程与面向对象编程

那么,让我们从第一个区别开始:函数式编程与面向对象编程。

在通常与函数式编程相关的众多原则、概念和技术中,我只想重点介绍其中一项,就我个人经验而言,它对我设计应用程序的方式产生了最大的影响。我认为它尤其重要,因为它易于移植,几乎可以应用于所有编程语言。

纯函数

这就是纯函数的概念。

纯函数是指对于给定的输入,始终返回相同结果的代码片段。始终如此。也就是说,相同的输入,相同的输出。

为什么纯函数如此重要?纯函数有很多优点,但最关键的一点是,纯函数更容易理解,因为纯函数内部的所有代码都只依赖于输入参数。因此,理解纯函数需要记住的上下文信息非常少。

纯函数就像物理定律一样,

法律

因为你知道纯函数是如何工作的,你可以相信它每次都会以相同的方式运行。

纯函数更容易理解,这也意味着它们更容易修改。

而改变,正是我们开发者赖以谋生的本能。

程序员始终处于维护状态。
《程序员修炼之道》

我们很少编写全新的代码。大多数时候,我们只是在现有系统上进行修改。即使你的项目是15分钟前创建的,如果你在修改现有代码,那么你实际上也已经是在进行代码修改了。

副作用

而谈到纯函数时,我们就不得不谈到它的邪恶孪生兄弟——副作用。

副作用会让你的代码难以理解,因为突然之间,仅仅查看代码本身已经不足以理解它了。你还需要理解它的所有依赖项、所有使用的库、数据库中所有可能的状态、网络的所有可能状态,以及多个线程同时执行的所有操作。

你需要记住的背景信息非常多。

一旦出现副作用,

副作用

你不确定做出改变后会发生什么。

嗯,这算是个小小的副作用。这种情况我们经常遇到。你对应用程序的一侧做了一个很小的、你认为无关紧要的改动,结果另一侧一个完全不相关的功能突然就出问题了,而你却浑然不觉。

所以对我来说,函数式编程的一个关键见解是:副作用是敌人。

015-副作用-敌人

所以函数式编程的本质就是对抗和控制副作用。

副作用主要分为两大类:

副作用类型

  1. 那些会改变应用程序状态的副作用
  2. 然后我们还有 I/O 副作用。在这些 I/O 副作用中,我想区分输入副作用(有时也称为协同副作用)和输出副作用(简称副作用)。

状态

那么,我们先来谈谈如何对抗国家机器。

对于奖励系统,我们的应用程序必须跟踪每个客户的状态及其在奖励活动中的进度。

为了实现这一点,您可以想象应用程序维护一个映射表,以客户端 ID 为键,以某种 ClientBonus 对象为值。这个 ClientBonus 对象本身可能包含 Client 对象和 bonus 对象。此外,它还需要跟踪已进行的存款,因此可以维护一个 DepositList,其中包含多个 Deposit 对象,而每个 Deposit 对象又可以包含更多子对象。类似地,它还需要跟踪客户端的投注情况。

所以这些就是我们的对象图,每个客户端都会有一个这样的图。

在状态管理方面,我通常会让每个对象负责自身的状态变更。因此,Map 对象会拥有自己的一套机制,一段独立的代码来管理其内部状态,以确保多个线程在尝试操作或读取 Map 时,能够看到一致的状态。

面向对象中的状态管理

类似地,ClientBonus 对象也负责操作其自身的内部状态,并提供一致的视图,并且每个对象都是如此。

从复杂性的角度来看,每个对象都有自己的机制来控制其状态意味着什么?这些小型机制都可能产生副作用,每次修改代码时都必须考虑这些副作用。你不仅要考虑需要编写的业务逻辑,还要考虑由于并发访问对象而导致的任何时序问题。

所以代码中将业务规则和并发规则混杂在了一起。

所以 Clojure 告诉我们,为了简化操作,你需要做的就是将应用程序逻辑、业务逻辑与任何状态管理分离,这样你就不需要同时考虑两者了。

那么我们该如何实现呢?首先,我们要让所有东西都变得不可变,所有东西都是一个值,甚至包括存储所有 ClientBonus 对象的映射表。既然一切都是不可变的,那么在编写应用程序逻辑时,您无需考虑时序问题,也无需关心其他线程同时在做什么,因为它们都无法修改您的对象图。这让您摆脱了思维的束缚,大大简化了应用程序逻辑的编写。

对于状态管理部分,Clojure 提供了一个名为 Atom 的构造或类,它基本上与 Java AtomicReference 相同,但功能更多一些。

atom =~ jucaAtomicReference

让我们来看看原子是如何运作的。

原子持有对整个不可变值(即整个状态)的引用,而你作为开发人员的任务是编写一个函数,该函数以当前状态作为参数,并生成新状态,原子机制将确保从一个状态到下一个状态的转换以原子方式完成。

为了更好地理解这一点,让我们看看如果两个线程同时尝试修改该状态会发生什么。

原子状态管理

两个线程都会获取初始状态并开始计算下一个状态。假设线程 1 比线程 2 先完成。此时,线程 1 尝试将原子状态更改为新的绿色值。为此,它指示原子执行原子比较和交换操作。由于用于计算绿色状态的值仍然是白色值,因此原子将其状态更改为绿色值。

现在线程 2 执行完毕,当它尝试改变原子状态时,比较和交换操作失败,因为原子不再指向白色状态。因此,线程 2 必须重新启动,但这次要使用绿色值。

所有这些关于重试和检测冲突的机制都由原子提供,因此作为开发人员,您只需编写一个纯函数即可。

为了确保我们理解一致,我想指出两点。

第一种情况是,当时原子只有白色、绿色和红色三种有效值。没有人见过蓝色值。

另一方面,例如假设存在另一个线程(线程 3),它在时间 0 读取原子当前状态并保持对该状态的引用一段时间。随着时间从 t0 推移到 t1 和 t2,线程 3 仍然会看到初始状态,即白色状态。由于该值是不可变的,任何人都无法修改它,这意味着线程 3 可能正在使用过时的值。

你可能会想,哇,如果每次我想修改任何东西都要创建一个全新的图,那么这种不可变性岂不是会非常慢、非常耗费资源?实际上,它的确会慢一些,但并没有你想象的那么慢。

假设你处于这种状态,

对象图

要计算新的状态,你需要修改该投注对象中的某个字段。当然,你不能修改任何现有字段,所以你需要创建一个新的投注对象。由于该投注对象属于一个投注列表(BetList),而所有数据都是不可变的,这意味着你必须创建一个新的投注列表,进而创建一个新的客户端奖励(ClientBonus)对象,并在哈希映射(HashMap)中创建一个新的存储桶(bucket)。

结构共享

绿色状态和白色状态的区别就在于这四点。要构建绿色状态,你只需要创建四个新对象,其他所有对象都可以复用。之所以可以这样做,是因为这些对象都是不可变的,而我们知道共享不可变对象是安全的。这种技术称为结构共享。

虽然这仍然比修改 Bet 对象中的一个字段要慢,但成本仍然非常低,特别是如果我们将其与这种方法的优势进行比较的话。

这个帖子安全吗?
每个Java开发者每天都会问自己这个问题。

你有没有问过自己这个问题?有了不可变性和原子,你仍然会问这个问题,但回答这个问题的规则要简单得多,因为它们不涉及 Java 内存模型和“先发生后发生”语义。

线程安全规则

第一条规则是原子内部的状态始终保持一致,因为一切都是不可变的,所以不可能出现半成品状态。这已经大大简化了代码的复杂性。

第二条规则是,计算新状态的函数必须是纯函数,因为它可能会运行多次。

正如我们在 Thread-3 的例子中看到的那样,在这个纯函数之外做出的任何决定都可能使用过时的数据,或者受到竞态条件的影响。

从第三条规则还可以看出,如果你的代码必须查看两个原子才能做出某个决定,那么这个决定就不是原子性的。

那么这一切是如何影响我们的Java代码的呢?

首先,显而易见的是,所有领域类都是不可变的,因此所有字段,包括任何映射或列表,都是不可变的。

public class ClientBonus {

    private final Client client;
    private final Bonus bonus;
    private final DepositList deposits;


Enter fullscreen mode Exit fullscreen mode

至于状态管理部分,我们实际上并没有使用 AtomicReference 来存储包含所有 ClientBonus 的整个映射。

就我们而言,由于一个客户的行为不会影响另一个客户的奖励结果,我们的应用程序逻辑只需要客户奖励保持一致,而不需要所有当前客户奖励的一致视图。

所以我们实际上是使用 ConcurrentHashMap 来保存状态,然后每个值都会有自己的小机制来推进时间。

并发哈希映射

该机制由 ConcurrentMap 的 compute 方法族提供,它基本上提供了与 Clojure 的 atom 相同的语义,但它是在每个键级别上进行的。

public interface ConcurrentMap<> extends Map<> {

V compute(K key, BiFunction<> remappingFunction) 
V computeIfAbsent(K key, Function<> mappingFunction) 
V computeIfPresent(K key, BiFunction<> remappingFunction) 

}
Enter fullscreen mode Exit fullscreen mode

这就是保存状态的类的样子。

public class TheStateHolder {
    private final Map<Long, ClientBonus> state = new ConcurrentHashMap<>();
    public ClientBonus nextState(Long client, Bet bet) {
        return state.computeIfPresent(
                client,
                (k, currentState) -> currentState.nextState(bet));
    }
Enter fullscreen mode Exit fullscreen mode

它包含 ConcurrentHashMap,并且每次应用程序获得新数据时,它都会以原子方式计算新状态。

在我们的案例中,我们决定由 ClientBonus 本身来计算新的状态。

public class ClientBonus {
...
    public ClientBonus nextState(Bet bet) {
        ...
    }

Enter fullscreen mode Exit fullscreen mode

因此,nextState 函数必须是一个纯函数。

这样我们就成功地将状态管理与应用程序逻辑分离了。

效果

既然我们知道如何对抗国家,那么让我们来看看我们可以对效果做些什么。

效果是指我们的应用程序为了改变外部世界的状态而必须执行的操作。

就我们而言,这些效果包括向用户发送有关奖励进度的通知,或者将价格支付到客户的账户中。

Clojure 和 Java 一样,并非像 Haskell 那样的纯语言,因此它实际上并没有提供任何处理 I/O 的特殊工具。那么,让我们看看如何处理副作用。

通常,在我们的应用程序中,我们会这样写:一个依赖于某个接口的服务对象,然后在运行时注入一些依赖项。

服务设计

如果你仔细想想这种服务是什么样的,你会发现它主要负责两件事:一是决定应用程序需要执行哪些副作用,二是执行这些副作用并处理执行过程中可能出现的任何错误或异常。因此,在编写服务代码时,你必须同时考虑到这两点。

因此,如果我们想采用更注重功能性的方法,就需要将这两件事分开,以便独立处理。一方面,我们需要决定需要实现哪些效果;另一方面,我们必须处理与外部世界交互的繁琐细节。

为了确定需要执行哪些效果,在我们的业务逻辑中,我们可以不只计算下一个状态,而是同时计算效果。这样一来,计算要执行的效果就成为了我们纯业务逻辑的一部分,成为了我们纯函数的一部分。

public class ClientBonus {
...
    public Pair<ClientBonus,Effects> next(Bet bet) {
        ...
    }

Enter fullscreen mode Exit fullscreen mode

请注意,这也意味着我们的效应在我们的应用程序中成为显式的一等概念。

通知类

这将是一个表示通知客户奖金进度效果的类的示例。

即使系统的另一部分将执行此效果并处理错误,我们的业务逻辑仍然可以决定如何处理这些效果和错误。

例如,在我们的业务逻辑中,我们可以将此效果包装在忽略错误策略中,而在其他情况下,它可能决定正确的策略是停止 JVM。

政策包裹

除了错误处理策略之外,应用程序逻辑还可以决定效果是否必须按顺序执行,因此如果一个效果失败,其余效果将不会执行。

顺序执行

或者,这些效果可能是独立的,因此一个效果的错误不应影响其他效果,这也意味着这些效果可以并行执行。

对于我们的附加应用程序,我们决定不在效果中构建任何这些灵活性,因为我们认为没有必要,而是采用非常严格、非常静态的方式来定义效果,并在类型系统中编码有效和可能的效果链。

但是,既然我们已经了解了必须运行哪些副作用,我们仍然需要执行它们,仍然需要运行它们,所以需要一些代码来解释这一副作用链的描述。

就我们而言,由于该描述的结构非常僵化,我们选择让每个效果自行决定如何运行。

public interface Effect {
    void run(AllDependencies dependencies);
}
Enter fullscreen mode Exit fullscreen mode

请注意,我们在这里传递执行这些副作用所需的所有依赖项,例如 http 或 JMS 客户端。

传递 AllDependencies 对象的好处在于,它可以非常清楚地表明哪些方法是不纯的,因为要执行任何副作用,该方法需要将该依赖项对象声明为参数。

它的缺点是传递起来有时有点麻烦,而且 AllDependencies 类相当丑陋,因为它需要保存和提供大量的依赖项。这个 AllDependencies 类几乎就像你的 Spring Context 一样。

这就是我们的代码:

Pair<ClientBonus, Effects> pair = theStateHolder.nextState(bet);
pair.effects.run(dependencies);
Enter fullscreen mode Exit fullscreen mode

我们计算出下一个状态和要执行的效果,然后执行这些效果。

但问题是,这个帖子安全吗?

根据我们之前看到的关于原子的规则,很明显并非如此,因为其中一条规则是,在计算新状态的纯函数之外发生的任何事情都可能成为竞争条件。

这里存在竞态条件吗?

假设有两个线程分别计算新的状态和要执行的效果。

竞态条件

原子机制确保这两个线程做出的决策是原子性的、一致的且相互隔离的。目前为止,一切顺利。

但是,既然我们已经脱离了原子机制,就可能出现竞争条件,所以线程 2 可能会在线程 1 之前执行其副作用。

竞态条件

这是否可以接受,取决于您的业务需求。

所以,原子并不能消除所有竞争条件,但它们应该能让竞争条件何时发生变得更加明显。

如果你的应用程序中不能接受这种竞态条件,那么一个可能的选择是使用代理。

Clojure Agent 本质上类似于原子,但它额外保证了单线程运行。如果您熟悉 Actor,那么它们在并发模型方面与 Actor 有些相似。

对于我们的附加应用程序来说,这种竞态条件是不可接受的,但我们决定不使用 Agent,而坚持使用原子,为什么呢?

我们当时运行着多个奖励服务实例,所以我们现在进入了分布式系统编程的领域。

由于我们的应用程序需要执行的副作用不能以原子方式完成,分布式系统理论告诉你,你必须在至少一次语义和至多一次语义之间做出选择。

就我们而言,我们会逐一分析每一种效应,并针对每一种效应判断哪种更合适。对于那些至少出现过一次的效应,我们没有进行任何协调处理。

对于那些需要至多一次语义的情况,我们使用关系数据库作为协调机制,因为数据库提供了 ACID 保证。

竞态条件数据库修复

因此,在执行需要至多一个语义的效果之前,应用程序会检查数据库,以便在多个实例之间存在执行相同效果的竞争时,只有一个实例会获得执行许可。

请注意,这些仍然是我们的纯函数,也就是计算效果的函数。

最多一次

这些决定何时以及哪些效果需要至少一次或至多一次语义的规则,我们通过将效果包装在“至多一次”策略中来实现这一点。

协同效应

最后一种副作用是协同效应。协同效应是指我们的应用程序做出决策所需的输入数据。

对于我们的奖励申请,我们基本上需要 4 项信息:哪些客户注册了哪些奖励,客户的投注和存款情况,以及由于客户获得奖励的时间有限,我们还需要知道时间。

正如我之前提到的,我们将所有状态都保存在内存中,之所以能够做到这一点,是因为客户端事件的输入源是 Kafka。如果您不熟悉 Kafka,可以把它想象成一个不可变的消息队列,它会记住所有经过的消息。

这样,当奖励应用启动时,它会向 Kafka 请求最近几个月的所有消息,并根据这些事件重新计算当前状态。此外,每个事件都会带有时间戳,因此应用会在其逻辑中使用事件发生的时间作为当前时间。

这基本上就是事件溯源。从本质上讲,事件溯源和函数式编程有很多共同之处。

事件溯源

事件溯源和函数式编程相辅相成。

好处

所以,把所有东西放在一起,整个过程看起来就是这样。

public class KafkaConsumer {
    private AllDependencies allDependencies;
    private TheStateHolder theStateHolder;

    public void run() {
        while (!stop) {
            Bet bet = readNext();
            Effects effects = theStateHolder.event(bet);
            effects.run(allDependencies);
        }
    }

}
Enter fullscreen mode Exit fullscreen mode

这是你选择的依赖注入框架将要注入的两个依赖项。

包含执行效果所需的所有依赖项以及应用程序状态的对象。

这里我们使用 Kafka 轮询 API,因此 KafkaConsumer 将是一个线程,它将从 Kafka 主题中读取新事件。

然后要求我们的状态推进时间,更新状态,并返回我们需要执行的效果。

最后,我们要求效果自行显现。

遵循这种方法,我们的代码会发生一些有趣的变化:

好处

首先,我们的业务对象没有 getter 或 setter。

此外,我们的业务逻辑也更加简洁,因为它不再包含锁、同步方法、try/catch 块和日志记录,因为所有这些操作都将在系统的不同部分完成。这大大减少了业务逻辑中的冗余信息。

此外,我们的单元测试中没有使用模拟对象,因为业务逻辑的输入和输出都是纯值,所以单元测试比较简单。为了测试副作用以及代码库中所有不纯的部分,我们决定使用少量的全栈测试或集成测试。

最后,由于我们不需要进行任何模拟,因此代码库中没有任何无用的接口。我所说的无用接口,指的是那些只有一个生产环境实现的接口。

功能核心,命令式外壳

现在这种设计或架构风格被称为功能核心、命令式外壳。

功能核心,必要外壳

函数式核心是我们所有纯函数所在的地方,它没有任何副作用。我们尽可能多地在函数式核心中做出决策,因为这样更容易测试和修改。

命令式代码外壳承载着所有的副作用,以及所有关于错误处理、状态和 I/O 的丑陋代码。我们力求使命令式代码外壳完全摆脱任何条件判断和决策。

我们的目标是尽可能扩大功能核心,同时尽可能缩小必要外壳。

由于还有其他建筑也采用了相同的圆形形状,我想明确说明一点。

不太实用

如果你的代码像这样,核心类依赖于核心包中的某个接口,然后在运行时注入实际的实现,那么 ClientBonus 中的这段代码就不是函数式的。你的函数式核心不能依赖于任何可能产生副作用的代码,即使是间接的也不行。

我并不是说你不应该这样做,我只是指出,当你这样做时,所有这些代码都属于命令式外壳,因此你无法获得函数式核心带来的好处。

动态(与静态)类型

我们来谈谈下一个主要区别。Clojure 是一种动态语言,而 Java 是一种静态语言。

一个典型的 Clojure 程序如下所示:

clientBonus = Map.of(
        "client", Map.of("id", "123233"),
        "deposits",
        List.of(
                Map.of("amount", 3,
                            "type", "CASH"),
                Map.of("amount", 234,
                            "type", "CARD")));

((List) clientBonus.get("deposits"))
        .stream()
        .collect(
                Collectors.summarizingInt(
                        m -> (int) ((Map) m).get("amount")));
Enter fullscreen mode Exit fullscreen mode

首先,我们的领域对象只是一堆映射和列表。然后,我们的业务逻辑就是操作这些映射和列表。

我不确定你对此有何看法,但就我个人对Java的理解而言,

外星人

这简直是​​地狱,这段代码完全就是无法维护的代名词。如果我的团队里有人写了这段代码,我会要求他们给出非常非常充分的解释,说明他们为什么要这么做。

所以在我们的附加应用程序中,我们决定完全不这样做,我们没有引入 Clojure 的动态类型,而只是使用 Java 类型系统。

但令人惊讶的是,在编写 Clojure 代码时,这种动态类型问题并不那么突出,我认为这是因为 Clojure 核心 API 是专门为处理这种动态数据结构而设计的,因此比 Java API 要方便得多。

但是,一旦你编写了足够多的 Clojure 代码,你的思维就会开始紊乱,然后当你回到 Java 时,你就会开始产生一些非常奇怪的想法。

所以当你输入这个类名时,

public class Bet {    
    private String id;   
    private int amount;
    private long timestamp;
}
Enter fullscreen mode Exit fullscreen mode

你可能会开始思考,创建一个新类有什么价值?与使用普通的映射相比,我能从中获得什么好处?

你会发现,首先得到的是一个几乎没用的 toString 方法,而且 equals 和 hashCode 的实现也有问题。这真的很烦人,但至少我们还有 Lombok。

但你会失去什么呢?你会突然失去地图的所有功能,全部功能,更糟糕的是,所有与地图相关的、能够理解地图的代码都将无法与这个新类一起使用。Java 核心 API 中没有任何代码可以与这个类配合使用,除了反射 API 之外。

此外,你在 GitHub 上能找到多少个与这个新类兼容的库?一个也没有。

正是在这一点上,你开始明白艾伦·佩里斯所说的“

让 100 个函数操作同一个数据结构,比让 10 个函数操作 10 个数据结构要好。——
艾伦·佩里斯

每个新类都是一个新的数据结构,它本身不包含任何功能,并且与其他任何代码完全隔离。这会阻碍代码的重用性。

但如果我们只是把 Bet 对象作为普通数据保留下来呢?

{:type :bet
 :id "client1"
 :amount 23
 :timestamp 123312321323}
Enter fullscreen mode Exit fullscreen mode

你有一个合理的 toString 方法,就是你在这里看到的那个。

你还可以免费获得完整的 equals 和 hashCode 功能。

但更重要的是,你仍然可以使用编程语言自带的所有核心功能,所以你无需从零开始,可以重用大量代码。而且你还能找到与这段代码兼容的 GitHub 库。

Clojure 社区已经接受了使用纯数据来表示尽可能多的事物的理念。

例如,您可以使用纯数据来表示 HTTP 请求,因此您的 HTTP 服务器需要做的就是将此映射作为输入进行处理。

{:request-method :get
 :uri            "/foobaz"
 :query-params   {"somekey" "somevalue"}
 :headers        {"accept-encoding" "gzip, deflate" 
                  "connection" "close"}
 :body           nil
 :scheme         :http
 :content-length 0
 :server-port    8080
 :server-name    "localhost"}
Enter fullscreen mode Exit fullscreen mode

然后生成另一张地图作为输出。您可以使用相同的核心 API 来完成此操作。

{:status  200 
 :headers {"Content-Type" "text/html"} 
 :body    "Hello World"}
Enter fullscreen mode Exit fullscreen mode

想想你的考试会变得多么容易。

但是,你也可以将其他事物表示为纯数据。

SQL 查询:

{:select [:id :client :amount]
 :from   [:transactions]
 :where  [:= :client "a"]}
Enter fullscreen mode Exit fullscreen mode

以及数据库结果集:

[{:id 1 :client 32 :amount 3} 
 {:id 2 :client 87 :amount 7} 
 {:id 3 :client 32 :amount 4} 
 {:id 4 :client 40 :amount 6}]
Enter fullscreen mode Exit fullscreen mode

HTML 和 CSS:

[:html    
 [:body        
  [:p "Count: 4"]
  [:p "Total: 20"]]]
Enter fullscreen mode Exit fullscreen mode

配置:

{:web-server          {:listen 8080} 
 :db-config           {:host     "xxxx"                       
                       :user     "xxxx"                       
                       :password "xxxx"} 
 :http-defaults       {:connection-timeout 10000                      
                       :request-timeout    10000                       
                       :max-connections    2000} 
 :user-service        {:url "http://user-service"                       
                       :connection-timeout 1000}}
Enter fullscreen mode Exit fullscreen mode

甚至包括关于你的数据的数据,你的元数据:

{:id        :string 
 :name      :string 
 :deposits  [{:id        :string              
              :amount    :int              
              :timestamp :long}]}
Enter fullscreen mode Exit fullscreen mode

因此,通过接受使用纯文本数据的理念,最终您将使用相同的核心 API 来编写代码。

  1. 您的业​​务逻辑
  2. 你的基础设施代码
  3. 您的配置
  4. 您的元数据。

你只需要学习和掌握一个API。

所以 Clojure 中的动态类型并没有你想象的那么糟糕,因为它带来了很多好处。

动态(与静态)开发

但类型只是 Clojure 和 Java 之间动态与静态差异的其中之一。Clojure 提供了一种动态开发体验。这意味着什么呢?在 Clojure 中,当你需要开发一个新功能时,首先要做的是启动应用程序,然后你只需不断修改正在运行的应用程序,直到完成为止,而无需停止它。

你可以通过使用 REPL 来实现这一点。

当然,Java 现在有了 REPL 这样的工具,但是

Java 与 Clojure REPL 的对比

仅仅因为它们名字相同,并不意味着它们是同一个东西。

使用合适的 REPL,你无需构建或启动应用程序,而是从内部逐步构建应用程序。

一个合适的 REPL 能给你带来与 Unix Shell 相同的感觉和人体工程学体验。

一个合适的 REPL 就像始终有一个调试器连接到运行 JVM 的设备上一样。

合适的 REPL 是测试驱动开发工作流程中缺失的一环。

这次演讲是我尽力解释 REPL 是什么的尝试,但我认为 REPL 是非常非常陌生的东西,你真的需要亲身体验一下,因为它很难理解或想象。

使用 Java 时,我最怀念的就是一个完善的 REPL 程序。

Lisp(对比 Fortran)

好了,演讲的最后一部分。

显然,我们的附加项目没有使用 Clojure 语法,因为如果使用了,我就不会站在这里做这个演讲了。

但是,对于所有一看到 Lisp 就尖叫着逃跑的人,我有一些好消息要告诉你们。

首先,和其他现代 JVM 语言一样,在 Clojure 中你不需要输入分号!我想我们都同意,这比 Java 有了巨大的改进。

事实上,这个特性太棒了,它能极大地提高工作效率,以至于 Clojure 更进一步,在 Clojure 中,逗号是可选的!想想你输入过的数以百万计的逗号吧。

不再使用逗号

想象一下,如果能把那些时间都找回来,我至少会年轻20岁。

但我知道你在想什么。

括号呢?

那么,Lisp 语言中那些臭名昭著的括号怎么办呢?嗯,即使在这里,我也有好消息要告诉你。

.filter(removeCsvHeaders(firstHeader))
.map(splitCsvString())
.map(convertCsvToMap(csvHeaders))
.map(convertToJson(eventCreator))
Enter fullscreen mode Exit fullscreen mode
(filter not-header?)
(map parse-csv-line)
(map (partial zipmap headers))
(map ->event)
Enter fullscreen mode Exit fullscreen mode

这两段代码来自我的一个团队。我们在学习 Apache Spark 时,碰巧用 Clojure 和 Java 分别编写了基本相同的应用程序。这是应用程序的主要逻辑,正如你所看到的,它们看起来相同,但实际上存在一个重要的区别。

我们来数数括号。1、2、3……Java 版本有 16 个括号。Clojure 版本有多少个括号呢?10 个。所以 Clojure 版本少了 40% 的括号。

不仅如此,该应用程序的 Clojure 版本代码量只有原来的十分之一。

Java 更多括号

十分之一,想象一下,如果你能删除 90% 的代码。

好了,玩笑就到此为止。让我们来看看为什么 Lisp 程序员如此钟爱括号。为此,我深感抱歉,但我还得再给你们展示一些 Clojure 代码。

List.of(
        new Symbol("defn"),
        new Symbol("plus-one"),
        List.of(
                new Symbol("a"),
                new Symbol("b")),
        Map.of(
                new Keyword("time"), List.of(new Symbol("System/currentTimeMillis")),
                new Keyword("result"), List.of(
                        new Symbol("+"),
                        new Symbol("a"),
                        new Symbol("b"),
                        new Long(1))));
Enter fullscreen mode Exit fullscreen mode

这是一个典型的 Clojure 程序。我们定义了一个函数,该函数接受两个参数,并返回一个映射,该映射包含这两个参数之和加一。

好吧,Clojure 可能比这简洁一些,但本质上,这就是你编写 Clojure 代码时所做的。这是什么?你的代码只是列表和映射,这就是我们说的 Lisp 中“代码即数据”的含义,因为你看,这就是实际的数据。

因为它是数据,所以我们可以使用与业务逻辑、基础设施代码和配置完全相同的工具和 API 来操作、生成和分析它。

元编程,也就是编写能够编写程序的程序,本质上就是处理列表和映射。这非常简单,却又极其强大。

这就是为什么 Lispers 如此喜爱括号的原因。

概括

总而言之……

功能核心,必要外壳

尽量多编写纯函数,这样可以使你的应用程序更容易理解和修改。

类型平衡

使用 Clojure 之后,我发现动态类型和静态类型之间是一种权衡。诚然,Clojure 缺少一些优秀的 Java IDE 所提供的重构功能,而且我有时也会浪费时间去纠正一些拼写错误,但 Clojure 对数据的重视,在某种程度上弥补了这些不足。

发展经验平衡

但是,在体验过 Clojure 充满活力的开发体验之后,我永远都不想放弃它。

括号真可怕

请不要害怕括号。就像你不会在没有 IDE 的情况下编写 Java 代码一样,你也不会在没有 IDE 的情况下编写 Clojure 代码。IDE 会处理所有这些看似可怕的括号。记住,它们的存在是有充分且重要原因的。

最后,我想引用艾伦·佩里斯的另一句话:

如果一种语言不能影响你思考编程的方式,那么学习它就没有意义。

对我来说,Clojure 就是这样一种语言。它默认不可变性,支持函数式编程、动态类型和 REPL,采用 Lisp 语法和宏,一切都以简单数据的形式呈现。

所有这些都让我受益匪浅。它们改变了我解决问题的方式,改变了我构建应用程序的方式,也改变了我设计系统的方式。

但这些都不是 Clojure 最重要的教训。

学习 Clojure 过程中我最大的感悟,也是最深刻的教训,就是我一直以来对不同的想法都非常封闭,仅仅因为它们与我习惯的事物不同。

如果五六年前你们有人跟我说要我学动态 Lisp,我肯定会说“绝对不行,我才不会浪费时间”。然而,现在我却在这里向人推荐 Clojure。

Clojure 让我对不同的想法产生了好奇心,即使是那些最初看起来令人作呕的想法。

所以我鼓励大家今年或明年学习一门新的编程语言,不一定要是 Clojure。

语言

但要选择一些与你习惯的完全不同的东西,一些让你感到不安的东西,一些完全陌生的东西。

我相信,在这个过程中,你一定会学到一些你想运用到日常工作中的东西。

而最糟糕的情况是,

诡异的

那样只会让你显得更古怪,更难让人理解。

非常感谢您抽出时间。

但离开之前,请快速观看一下这段视频:

文章来源:https://dev.to/danlebrero/java-with-a-clojure-mindset-1p13